diff --git a/.devproxy.yaml b/.devproxy.yaml new file mode 100644 index 000000000..055a6cfe1 --- /dev/null +++ b/.devproxy.yaml @@ -0,0 +1,9 @@ +dns: + port: 1053 +http: + port: 80 + +routes: + - pattern: "debug" + target: "localhost:8082" + path: "/debug" diff --git a/.github/agents/lava-reviewer.agent.md b/.github/agents/lava-reviewer.agent.md new file mode 100644 index 000000000..448898972 --- /dev/null +++ b/.github/agents/lava-reviewer.agent.md @@ -0,0 +1,60 @@ +--- +name: Lava Reviewer +description: "Use when: reviewing Lava pull requests, auditing Go backend changes, checking architectural boundaries, and producing actionable fix recommendations with verification steps." +tools: [read, search, execute, todo] +argument-hint: "Provide PR context, changed files, or review focus (e.g., concurrency, API compatibility, proto workflow)." +user-invocable: true +disable-model-invocation: false +--- + +You are a specialist code reviewer for the Lava repository. + +Your job is to assess change quality, identify concrete risks, and return concise, actionable fixes with a verification plan aligned to this repository. + +## Constraints + +- Focus on review and recommendations first; do not perform broad refactors unless explicitly requested. +- Keep comments evidence-based and tie findings to specific files/symbols/behaviors. +- Preserve public API and architectural boundaries unless change intent requires otherwise. +- Do not suggest editing generated protobuf files (`*.pb.go`) directly. +- Prefer minimal, high-signal findings over exhaustive low-value commentary. + +## Repository-Specific Guardrails + +- Treat `Taskfile.yml` as local workflow source of truth. +- For standard verification, prioritize: + 1. `task test` + 2. `task lint` +- If `.proto` or `protobuf.yaml` is touched, require: + 1. `task proto:fmt` + 2. `task proto:lint` + 3. `task proto:gen` + 4. then `task test` and `task lint` +- Check module placement decisions against Lava boundaries: + - `lava/` abstractions + - `core/` runtime capabilities + - `servers/` serving behavior + - `clients/` outbound clients + - `pkg/` reusable public components + - `internal/` internal-only implementation + +## Review Approach + +1. Inspect changed areas and summarize intent in 3-6 bullets. +2. Evaluate correctness risks (concurrency, lifecycle, error handling, resource leaks, API behavior changes). +3. Evaluate design fit (module boundary placement, reuse of existing patterns, compatibility impact). +4. Evaluate operability (logs, metrics/tracing implications, failure modes). +5. Propose minimal fixes with rationale and impact. +6. Return a prioritized verification checklist and expected pass criteria. + +## Output Format + +Use this structure: + +- **Scope understood**: what was reviewed. +- **Top findings**: prioritized list (`critical` / `high` / `medium` / `low`). +- **Suggested fixes**: minimal patch strategy per finding. +- **Validation plan**: exact commands and what success looks like. +- **Residual risks**: anything not fully verifiable from current context. + +When there are no material issues, explicitly state **"No blocking issues found"** and still provide a short validation summary. \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..38592e8d8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,69 @@ +# Project Guidelines for Lava + +## Scope + +These instructions are project-wide defaults for this repository. Keep changes focused, minimal, and aligned with existing patterns. + +## Architecture + +- `lava/`: core public interfaces and contracts (`Middleware`, routers, request/response abstractions). +- `core/`: runtime capabilities (supervisor, scheduler, tunnel, logging/metrics/tracing, debug, DI builder). +- `servers/`: service hosts (`https` on Fiber, `grpcs` with gateway integration). +- `clients/`: outbound client implementations (`grpcc`, `resty`). +- `pkg/`: reusable public utilities/components (including gateway and helpers). +- `internal/`: repository-internal implementation details/examples; avoid exposing as public API. + +Place new code by responsibility: +- Cross-protocol abstractions -> `lava/` +- Runtime framework capability -> `core//` +- HTTP/gRPC serving behavior -> `servers/` +- Reusable public helper/component -> `pkg/` +- Internal-only implementation -> `internal/` + +## Build and Test + +Run commands from repository root. + +Primary local workflow (source of truth: `Taskfile.yml`): +- `task test` (Go tests: short + race + cover) +- `task lint` (golangci-lint) + +When `.proto` files or `protobuf.yaml` change, run: +- `task proto:fmt` +- `task proto:lint` +- `task proto:gen` +- then `task test` and `task lint` + +CI reference is `.github/workflows/lint-test.yml` (lint + gotestsum-based tests). + +## Conventions + +- Follow standard Go formatting and idioms (`gofmt`, package naming, error-last returns). +- Prefer wrapping errors with context (project commonly uses `github.com/pkg/errors`). +- Keep public APIs and behavior stable unless the task explicitly requires breaking changes. +- Do not edit generated protobuf files (`*.pb.go`) manually; regenerate via proto tasks. +- Prefer updating existing module patterns instead of introducing new architectural styles. + +## Pitfalls to Avoid + +- `Taskfile.yml` is authoritative for local commands; docs are guidance. +- Lint settings may apply automatic fixes (`.golangci.yaml` has `issues.fix: true`), so re-check diffs after lint. +- Go toolchain target is defined in `go.mod` (`go 1.25.0`). +- For protobuf generation, keep `protobuf.yaml` base module aligned with Go module path (`github.com/pubgo/lava/v2/pkg`). + +## Key References + +- `README.md` +- `docs/architecture-v2.md` +- `docs/design-v2.md` +- `docs/development.md` +- `Taskfile.yml` +- `.github/workflows/lint-test.yml` + +## Representative Examples + +- Service lifecycle and management: `core/supervisor/` +- DI registration patterns: `core/lavabuilder/` +- HTTP server composition: `servers/https/server.go` +- gRPC + gateway composition: `servers/grpcs/server.go` +- Gateway behavior and routing: `pkg/gateway/` diff --git a/.github/instructions/backend-go.instructions.md b/.github/instructions/backend-go.instructions.md new file mode 100644 index 000000000..5f3585df4 --- /dev/null +++ b/.github/instructions/backend-go.instructions.md @@ -0,0 +1,38 @@ +--- +name: backend-go +description: "Use when: editing Go backend code in Lava (core/servers/clients/pkg/lava/cmds/internal), including refactors, bug fixes, and tests." +applyTo: "{core,servers,clients,pkg,lava,cmds,internal}/**/*.go" +--- + +# Lava Backend Go Instructions + +- Keep changes minimal and consistent with existing patterns in the target module. +- Place code by responsibility: + - Cross-protocol contracts -> `lava/` + - Runtime framework capabilities -> `core//` + - Service hosting behavior -> `servers/` + - Outbound client behavior -> `clients/` + - Reusable public helpers/components -> `pkg/` + - Internal-only implementation -> `internal/` +- Preserve public APIs unless the task explicitly requires a breaking change. +- Prefer contextual error wrapping; this repository commonly uses `github.com/pkg/errors`. +- Never hand-edit generated protobuf files (`*.pb.go`); regenerate via proto tasks. +- Follow Go idioms (`gofmt`, package naming, error-last returns). +- Reuse existing module patterns before introducing new architectural styles. + +## Validation + +- Run from repository root. +- Standard verification flow: + - `task test` + - `task lint` +- If `.proto` or `protobuf.yaml` changes are involved, run first: + - `task proto:fmt` + - `task proto:lint` + - `task proto:gen` + +## Pitfalls + +- Treat `Taskfile.yml` as the local source of truth. +- Lint may auto-fix files (`.golangci.yaml` sets `issues.fix: true`); re-check diffs after lint. +- Keep Go toolchain aligned with `go.mod` (`go 1.25.0`). \ No newline at end of file diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index f3cb3a75c..76a483fe0 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -21,7 +21,6 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: - skip-go-installation: true args: --timeout 3m --verbose test: diff --git a/.gitignore b/.gitignore index 5fcf71a27..30d597434 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ bin /example/bin .proto proto-vendor +.local +/internal/examples/grpcweb/frontend/node_modules diff --git a/.lava.yaml.example b/.lava.yaml.example new file mode 100644 index 000000000..b3433e9cd --- /dev/null +++ b/.lava.yaml.example @@ -0,0 +1,84 @@ +# lava 配置文件示例 +# 配置文件可以放在以下位置(按优先级从高到低): +# 1. .lava/lava.yaml +# 2. .lava.yaml +# 3. lava.yaml + +# watch 命令配置 +watch: + # watcher 列表,可以配置多个 watcher + watchers: + # watcher 1:监控 proto 文件 + - name: "proto" + directory: "./proto" + patterns: + - "*.proto" + commands: + - "protobuild gen" + ignore: + - ".git" + - "vendor" + ignore_patterns: + - "*.tmp" + - "*~" + run_on_startup: false + timeout: 30 + + # watcher 2:监控 go 文件 + - name: "go" + directory: "." + patterns: + - "*.go" + commands: + - "go build ./..." + ignore: + - ".git" + - "node_modules" + - "vendor" + - "dist" + - "build" + ignore_patterns: + - "*.tmp" + - "*~" + - ".DS_Store" + run_on_startup: false + timeout: 30 + + # watcher 3:监控配置文件 + - name: "config" + directory: "./internal/configs" + patterns: + - "*.yaml" + commands: + - "echo 'Config changed, reloading...'" + ignore: + - ".git" + run_on_startup: false + timeout: 10 + +# curl 命令配置 +curl: + # 网关地址 + addr: "http://127.0.0.1:8080" + + # 网关前缀 + prefix: "/api" + + # 请求超时时间 + timeout: "15s" + + # 是否跳过 TLS 校验 + insecure: false + + # 是否对 JSON 响应进行格式化 + pretty: true + + # gateway 路由信息的 expvar 名称 + vars_name: "grpc-server-info" + + # 默认的请求头 + headers: + - "Content-Type: application/json" + + # 默认的查询参数 + queries: {} diff --git a/README.md b/README.md index 068d275da..68f9f4423 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,102 @@ -# [lava文档](https://www.yuque.com/pubgo/lava/readme)|[example](pkg/example/services) +# Lava -> lava 是一个经过企业实践而抽象出来的微服务中台集成框架 +Lava 是一个面向 Go 微服务场景的中台集成框架,提供统一的服务抽象、调试能力、网关能力、隧道能力与开发工具链。 -1. 配置管理, 配置驱动开发, 本地配置, 配置中心, 配置变更 +## 核心能力 -2. 对象管理, 通过依赖注入框架dix管理对象, 可结合配置中心动态变更, 对象无感变更 +- 统一服务抽象:HTTP / gRPC / 定时任务 / Tunnel Gateway +- 统一中间件模型:`lava.Middleware` 一套接口覆盖多组件 +- 可观测性内建:日志、指标、调试路由(`/debug`) +- 网关能力:`pkg/gateway` 提供 HTTP/JSON 到 gRPC 的转换 +- 本地开发工具:`watch`、`curl`、`devproxy`、`fileserver` +- Protobuf 工程化:`protobuf.yaml` + `task proto:*` -3. runtime抽象, cli, gin, grpc, task等服务抽象统一的entry, 统一使用习惯, 多服务子命令运行 +## 快速开始 -4. plugin抽象, 结合运行顺序, 启动初始化, 配置变更, 对象管理, 对所有资源进行管理 +### 1) 环境要求 -5. 便捷protobuf生成管理, lava集成protoc命令去管理protobuf依赖,插件,版本和编译, 只需要一个protobuf.yaml配置文件 +- Go `1.25+` +- Task(推荐) -6. grpc自动注册, 通过自定义protoc-gen-lava管理grpc注册函数, 并自动识别服务的handler +### 2) 获取源码 -7. 调试友好, 便捷的系统日志和业务日志集成, 丰富且详细的debug api可以查看系统的配置参数和细节 +```bash +git clone https://github.com/pubgo/lava.git +cd lava +go mod tidy +``` + +### 3) 基础检查 + +```bash +task test +task lint +``` + +### 4) 构建并体验 CLI + +```bash +go build -o lava . +./lava +``` + +## 当前入口命令(`main.go`) -8. 自动生成swagger和http rest client文档, 方便测试和集成 +> 以仓库根入口 `main.go` 为准。 -9. tracing和metric自动集成, 通过reqId打通业务日志和tracing日志, 便于系统异常排查和跟踪 +| 命令 | 说明 | +| ----------------------- | ----------------------------------------------- | +| `lava watch` | 文件变更监听并自动执行命令 | +| `lava curl` | 面向 Gateway 的 HTTP 调试客户端(支持 `login`) | +| `lava tunnel gateway` | 启动 Tunnel Gateway | +| `lava fileserver ` | 本地目录静态文件服务 | +| `lava devproxy` | 本地开发代理(DNS + HTTP 反向代理) | -10. 统一抽象的middleware, gin和grpc的server以及client共享一套middleware抽象, 定义一套middleware作用于所有组件 +## 架构速览 -11. 统一protobuf定义grpc和http服务, 便于生成swagger和sdk, 方便第三方调用 +```mermaid +flowchart TD + U[开发者/调用方] --> CLI[lava CLI] + U --> HTTP[HTTP Client] + U --> GRPC[gRPC Client] -12. 专注于业务开发,把额外的组件抽象成一个独立的服务 + CLI --> CMDS[cmds/*] + HTTP --> SHTTP[servers/https] + GRPC --> SGRPC[servers/grpcs] + SHTTP --> CORE[core/*] + SGRPC --> CORE + CMDS --> CORE -## install + CORE --> GW[pkg/gateway] + CORE --> DEBUG["/debug routes"] + CORE --> OBS[logging/metrics/tracing] + CORE --> TUNNEL[core/tunnel] +``` -```shell -brew install go-task/tap/go-task -brew install go-task -eval "$(task --completion zsh)" +## 文档导航 + +- 文档总览:`docs/README.md` +- 架构文档(含流程图):`docs/architecture-v2.md` +- 设计文档(含关键抽象):`docs/design-v2.md` +- 命令文档:`docs/lava-command.md` +- 模块总览:`docs/modules/README.md` + - Core:`docs/modules/core.md` + - Servers:`docs/modules/servers.md` + - Clients:`docs/modules/clients.md` + - Pkg:`docs/modules/pkg.md` + - Cmds:`docs/modules/cmds.md` + +## Protobuf 流程 + +```bash +task proto:fmt +task proto:lint +task proto:gen ``` + +配置位于 `protobuf.yaml`,生成代码输出到 `pkg/proto`。 + +## 许可证 + +本项目使用 `LICENSE` 中定义的开源许可证。 diff --git a/clients/grpcc/client.go b/clients/grpcc/client.go index cb078b41d..bde5109ed 100644 --- a/clients/grpcc/client.go +++ b/clients/grpcc/client.go @@ -80,7 +80,7 @@ func (t *clientImpl) NewStream(ctx context.Context, desc *grpc.StreamDesc, metho conn := t.Get().MapErr(func(err error) error { return errors.Wrapf(err, "failed to get grpc client, service=%s, method=%s", t.cfg.Service, method) }) - return result.FlatMapTo(conn, func(val grpc.ClientConnInterface) (r result.Result[grpc.ClientStream]) { + return result.MapValTo(conn, func(val grpc.ClientConnInterface) (r result.Result[grpc.ClientStream]) { return result.Wrap(val.NewStream(ctx, desc, method, opts...)). MapErr(func(err error) error { return errors.Wrapf(err, "service %s:%s new stream failed", t.cfg.Service, method) diff --git a/clients/grpcc/grpccresolver/discoverybuilder.go b/clients/grpcc/grpccresolver/discoverybuilder.go index 71517d704..e78afe273 100644 --- a/clients/grpcc/grpccresolver/discoverybuilder.go +++ b/clients/grpcc/grpccresolver/discoverybuilder.go @@ -17,7 +17,6 @@ import ( "github.com/pubgo/lava/v2/core/discovery" "github.com/pubgo/lava/v2/core/service" "github.com/pubgo/lava/v2/internal/logutil" - "github.com/pubgo/lava/v2/pkg/proto/lavapbv1" ) func NewDiscoveryBuilder(disco discovery.Discovery) resolver.Builder { @@ -149,7 +148,7 @@ func (d *discoveryBuilder) Build(target resolver.Target, cc resolver.ClientConn, } // 注册中心删除服务 - if res.Unwrap().Action == lavapbv1.EventType_DELETE { + if res.Unwrap().Action == discovery.EventType_DELETE { d.delService(res.Unwrap().Service) } else { d.updateService(res.Unwrap().Service) diff --git a/clients/resty/README.md b/clients/resty/README.md new file mode 100644 index 000000000..28e1d66d8 --- /dev/null +++ b/clients/resty/README.md @@ -0,0 +1,188 @@ +# resty + +基于 fasthttp 的 HTTP 客户端实现,支持中间件、重试、认证等功能。 + +## 主要特性 + +- 基于 fasthttp 的高性能 HTTP 客户端 +- 支持中间件(访问日志、指标、恢复等) +- 支持重试机制 +- 支持代理设置 +- 支持 Basic Token 和 JWT Token 认证 +- 丰富的配置选项(超时、连接池等) + +## 参考项目 + +- [https://github.com/go-resty/resty](https://github.com/go-resty/resty) +- [https://github.com/imroc/req](https://github.com/imroc/req) +- [https://github.com/sony/gobreaker](https://github.com/sony/gobreaker) + +## 安装 + +```bash +go get github.com/pubgo/lava/v2/clients/resty +``` + +## 使用示例 + +### 基本用法 + +```go +import ( + "context" + "net/http" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/metrics" + "github.com/pubgo/lava/v2/clients/resty" +) + +func main() { + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + }, resty.Params{ + Log: log.Default(), + Metric: metrics.Default(), + }) + + req := resty.NewRequest(&resty.RequestSpec{ + Path: "/users/{id}", + Method: http.MethodGet, + }).SetParam("id", "123").SetQuery(map[string]string{ + "name": "test", + }) + + resp, err := client.Do(context.Background(), req).Unwrap() + if err != nil { + log.Fatal(err) + } +} +``` + +### 高级用法(带中间件) + +```go +import ( + "context" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/metrics" + "github.com/pubgo/lava/v2/lava" + "github.com/pubgo/lava/v2/clients/resty" +) + +func main() { + customMiddleware := func(next lava.HandlerFunc) lava.HandlerFunc { + return func(ctx context.Context, req lava.Request) (lava.Response, error) { + // 前置处理 + resp, err := next(ctx, req) + // 后置处理 + return resp, err + } + } + + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + }, resty.Params{ + Log: log.Default(), + Metric: metrics.Default(), + }, customMiddleware) +} +``` + +### 带认证的用法 + +```go +import ( + "context" + "net/http" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/metrics" + "github.com/pubgo/lava/v2/clients/resty" +) + +func main() { + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + EnableAuth: true, + JwtToken: "your-jwt-token", + }, resty.Params{ + Log: log.Default(), + Metric: metrics.Default(), + }) + + req := resty.NewRequest(&resty.RequestSpec{ + Path: "/protected", + Method: http.MethodGet, + }) + + resp, err := client.Do(context.Background(), req).Unwrap() + if err != nil { + log.Fatal(err) + } +} +``` + +### 带重试的用法 + +```go +import ( + "context" + "net/http" + "time" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/metrics" + "github.com/pubgo/lava/v2/clients/resty" +) + +func main() { + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + DefaultRetryCount: 3, + DefaultRetryInterval: 100 * time.Millisecond, + }, resty.Params{ + Log: log.Default(), + Metric: metrics.Default(), + }) + + req := resty.NewRequest(&resty.RequestSpec{ + Path: "/flaky", + Method: http.MethodGet, + }) + + resp, err := client.Do(context.Background(), req).Unwrap() + if err != nil { + log.Fatal(err) + } +} +``` + +## 配置选项 + +| 配置项 | 类型 | 说明 | 默认值 | +|-------|------|------|--------| +| BaseUrl | string | 基础 URL | - | +| ServiceName | string | 服务名称 | - | +| DefaultHeader | map[string]string | 默认请求头 | - | +| DefaultContentType | string | 默认内容类型 | - | +| DefaultRetryCount | uint32 | 默认重试次数 | 0 | +| DefaultRetryInterval | time.Duration | 默认重试间隔 | 0 | +| BasicToken | string | Basic 认证令牌 | - | +| JwtToken | string | JWT 认证令牌 | - | +| EnableProxy | bool | 是否启用代理 | false | +| EnableAuth | bool | 是否启用认证 | false | +| DialTimeout | time.Duration | 拨号超时 | 5s | +| ReadTimeout | time.Duration | 读取超时 | 10s | +| WriteTimeout | time.Duration | 写入超时 | 10s | +| MaxConnsPerHost | int | 每个主机的最大连接数 | 512 | +| MaxIdleConnDuration | time.Duration | 最大空闲连接时长 | 10s | +| MaxIdemponentCallAttempts | int | 最大幂等调用尝试次数 | 5 | +| ReadBufferSize | int | 读取缓冲区大小 | 4096 | +| WriteBufferSize | int | 写入缓冲区大小 | 4096 | +| MaxResponseBodySize | int | 最大响应体大小 | 2MB | + +## 更多示例 + +查看 [examples](./examples) 目录获取更多使用示例。 diff --git a/clients/resty/_doc.go b/clients/resty/_doc.go index 1f00a820b..60e6987be 100644 --- a/clients/resty/_doc.go +++ b/clients/resty/_doc.go @@ -1,5 +1,60 @@ package resty -// https://github.com/go-resty/resty -// https://github.com/imroc/req -// https://github.com/sony/gobreaker +// Package resty 提供了一个基于 fasthttp 的 HTTP 客户端实现,支持中间件、重试、认证等功能。 +// +// 主要特性: +// - 基于 fasthttp 的高性能 HTTP 客户端 +// - 支持中间件(访问日志、指标、恢复等) +// - 支持重试机制 +// - 支持代理设置 +// - 支持 Basic Token 和 JWT Token 认证 +// - 丰富的配置选项(超时、连接池等) +// +// 参考项目: +// - https://github.com/go-resty/resty +// - https://github.com/imroc/req +// - https://github.com/sony/gobreaker +// +// 示例: +// +// 基本用法: +// +// client := resty.New(&resty.Config{ +// BaseUrl: "https://api.example.com", +// ServiceName: "example-api", +// }, resty.Params{ +// Log: log.Default(), +// Metric: metrics.Default(), +// }) +// +// req := resty.NewRequest(&resty.RequestSpec{ +// Path: "/users/{id}", +// Method: http.MethodGet, +// }).SetParam("id", "123").SetQuery(map[string]string{ +// "name": "test", +// }) +// +// resp, err := client.Do(context.Background(), req).Unwrap() +// if err != nil { +// log.Fatal(err) +// } +// +// 高级用法(带中间件): +// +// customMiddleware := func(next lava.HandlerFunc) lava.HandlerFunc { +// return func(ctx context.Context, req lava.Request) (lava.Response, error) { +// // 前置处理 +// resp, err := next(ctx, req) +// // 后置处理 +// return resp, err +// } +// } +// +// client := resty.New(&resty.Config{ +// BaseUrl: "https://api.example.com", +// ServiceName: "example-api", +// }, resty.Params{ +// Log: log.Default(), +// Metric: metrics.Default(), +// }, customMiddleware) + diff --git a/clients/resty/aaa.go b/clients/resty/aaa.go index 0cc70cf58..af6ce0388 100644 --- a/clients/resty/aaa.go +++ b/clients/resty/aaa.go @@ -5,7 +5,6 @@ import ( "time" "github.com/pubgo/funk/v2/result" - "github.com/valyala/fasthttp" ) const ( @@ -13,11 +12,10 @@ const ( defaultRetryInterval = 10 * time.Millisecond defaultHTTPTimeout = 2 * time.Second defaultContentType = "application/json" - maxRedirectsCount = 16 defaultTimeout = 10 * time.Second Name = "resty" ) type IClient interface { - Do(ctx context.Context, req *Request) result.Result[*fasthttp.Response] + Do(ctx context.Context, req *Request) result.Result[*Response] } diff --git a/clients/resty/client.go b/clients/resty/client.go index 6d0eb03c7..44edd8823 100644 --- a/clients/resty/client.go +++ b/clients/resty/client.go @@ -10,7 +10,6 @@ import ( "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/result" "github.com/pubgo/funk/v2/retry" - "github.com/valyala/fasthttp" "github.com/pubgo/lava/v2/core/metrics" "github.com/pubgo/lava/v2/internal/middlewares/middleware_accesslog" @@ -20,19 +19,28 @@ import ( "github.com/pubgo/lava/v2/lava" ) +// Params 客户端参数结构 +// Log: 日志记录器 +// Metric: 指标收集器 type Params struct { Log log.Logger Metric metrics.Metric } +// New 创建一个新的 HTTP 客户端 +// cfg: 客户端配置 +// p: 客户端参数 +// mm: 自定义中间件 +// 返回: 初始化后的客户端实例 func New(cfg *Config, p Params, mm ...lava.Middleware) *Client { cfg = config.MergeR(DefaultCfg(), cfg).Unwrap() - middlewares := lava.Middlewares{ + middlewares := make(lava.Middlewares, 0, 4+len(mm)) + middlewares = append(middlewares, middleware_serviceinfo.New(), middleware_metric.New(p.Metric), middleware_accesslog.New(p.Log.WithFields(log.Fields{"service": cfg.ServiceName})), middleware_recovery.New(), - } + ) middlewares = append(middlewares, mm...) var backoff retry.Backoff @@ -46,28 +54,35 @@ func New(cfg *Config, p Params, mm ...lava.Middleware) *Client { handler := do(cfg) handler = lava.Chain(middlewares...).Middleware(handler) + + baseUrl := assert.Must1(url.Parse(cfg.BaseUrl)) + return &Client{ do: handler, log: p.Log, cfg: cfg, - baseUrl: assert.Must1(url.Parse(cfg.BaseUrl)), + baseUrl: baseUrl, backoff: backoff, } } var _ IClient = (*Client)(nil) -// Client is the IClient implementation +// Client 是 IClient 的实现,提供 HTTP 客户端功能 type Client struct { - do lava.HandlerFunc - log log.Logger - cfg *Config - baseUrl *url.URL - backoff retry.Backoff - pathTemplates sync.Map + do lava.HandlerFunc // 处理函数 + log log.Logger // 日志记录器 + cfg *Config // 客户端配置 + baseUrl *url.URL // 基础 URL + backoff retry.Backoff // 重试策略 + pathTemplates sync.Map // 路径模板缓存 } -func (c *Client) Do(ctx context.Context, req *Request) (r result.Result[*fasthttp.Response]) { +// Do 发送 HTTP 请求 +// ctx: 上下文 +// req: 请求对象 +// 返回: 响应结果 +func (c *Client) Do(ctx context.Context, req *Request) (r result.Result[*Response]) { defer result.Recovery(&r) if doRequest(c, req).ValueTo(&req.req).Throw(&r) { @@ -80,5 +95,5 @@ func (c *Client) Do(ctx context.Context, req *Request) (r result.Result[*fasthtt return r.WithErr(err) } - return r.WithValue(resp.(*responseImpl).resp) + return r.WithValue(&Response{resp: resp.(*responseImpl).resp}) } diff --git a/clients/resty/client_test.go b/clients/resty/client_test.go new file mode 100644 index 000000000..e0e7fbfd0 --- /dev/null +++ b/clients/resty/client_test.go @@ -0,0 +1,70 @@ +package resty + +import ( + "net/http" + "testing" + "time" + + "github.com/pubgo/funk/v2/retry" + "github.com/stretchr/testify/assert" +) + +// TestRequestCreation 测试请求创建 +func TestRequestCreation(t *testing.T) { + // 创建请求规范 + reqSpec := &RequestSpec{ + Path: "/api/users/{id}", + Method: http.MethodGet, + ContentType: "application/json", + Header: map[string]string{"X-Test": "value"}, + } + + // 创建请求 + req := NewRequest(reqSpec) + assert.NotNil(t, req) + assert.Equal(t, reqSpec, req.cfg) + assert.NotNil(t, req.header) + assert.NotNil(t, req.query) + assert.NotNil(t, req.params) +} + +// TestRequestBuilding 测试请求构建 +func TestRequestBuilding(t *testing.T) { + // 创建请求规范 + reqSpec := &RequestSpec{ + Path: "/api/users/{id}", + Method: http.MethodGet, + } + + // 创建请求并设置各种参数 + req := NewRequest(reqSpec). + SetParam("id", "123"). + SetQuery(map[string]string{"name": "test", "age": "20"}). + SetHeader("Authorization", "Bearer token123"). + AddHeader("X-Additional", "value"). + SetBody(map[string]string{"key": "value"}). + SetBackoff(retry.NewConstant(5 * time.Millisecond)) + + assert.NotNil(t, req) + assert.Equal(t, "123", req.params["id"]) + assert.Equal(t, "test", req.query.Get("name")) + assert.Equal(t, "20", req.query.Get("age")) + assert.Equal(t, "Bearer token123", req.header.Get("Authorization")) + assert.Equal(t, "value", req.header.Get("X-Additional")) + assert.NotNil(t, req.body) + assert.NotNil(t, req.backoff) +} + +// TestRequestSpecCreateRequest 测试RequestSpec的CreateRequest方法 +func TestRequestSpecCreateRequest(t *testing.T) { + reqSpec := RequestSpec{ + Path: "/api/test", + Method: http.MethodPost, + Header: map[string]string{"X-Test": "value"}, + } + + req := reqSpec.CreateRequest() + assert.NotNil(t, req) + assert.Equal(t, reqSpec.Path, req.cfg.Path) + assert.Equal(t, reqSpec.Method, req.cfg.Method) +} diff --git a/clients/resty/common.go b/clients/resty/common.go new file mode 100644 index 000000000..3f75d9cc9 --- /dev/null +++ b/clients/resty/common.go @@ -0,0 +1,228 @@ +package resty + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/goccy/go-json" + "github.com/pubgo/funk/v2/convert" + "github.com/pubgo/funk/v2/result" + "github.com/valyala/fasttemplate" + "golang.org/x/net/http/httpguts" +) + +// IsRedirect 检查状态码是否为重定向 +func IsRedirect(statusCode int) bool { + return statusCode == http.StatusMovedPermanently || + statusCode == http.StatusFound || + statusCode == http.StatusSeeOther || + statusCode == http.StatusTemporaryRedirect || + statusCode == http.StatusPermanentRedirect +} + +// FilterFlags 过滤内容中的标志 +func FilterFlags(content string) string { + for i, char := range content { + if char == ' ' || char == ';' { + return content[:i] + } + } + return content +} + +// ToString 将值转换为字符串 +func ToString(v any) string { + switch t := v.(type) { + case string: + return t + case bool: + return strconv.FormatBool(t) + case int: + return strconv.Itoa(t) + case int8: + return strconv.FormatInt(int64(t), 10) + case int16: + return strconv.FormatInt(int64(t), 10) + case int32: + return strconv.FormatInt(int64(t), 10) + case int64: + return strconv.FormatInt(t, 10) + case uint: + return strconv.FormatUint(uint64(t), 10) + case uint8: + return strconv.FormatUint(uint64(t), 10) + case uint16: + return strconv.FormatUint(uint64(t), 10) + case uint32: + return strconv.FormatUint(uint64(t), 10) + case uint64: + return strconv.FormatUint(t, 10) + default: + return fmt.Sprintf("%v", t) + } +} + +// PathTemplateRun 运行路径模板 +func PathTemplateRun(tpl *fasttemplate.Template, params map[string]any) (string, error) { + return tpl.ExecuteFuncStringWithErr(func(w io.Writer, tag string) (int, error) { + return w.Write(convert.StoB(ToString(params[tag]))) + }) +} + +// HeaderGet 获取 HTTP 头 +func HeaderGet(h http.Header, key string) string { + if v := h[key]; len(v) > 0 { + return v[0] + } + return "" +} + +// HeaderHas 检查 HTTP 头是否存在 +func HeaderHas(h http.Header, key string) bool { + _, ok := h[key] + return ok +} + +// HasPort 检查字符串是否包含端口 +func HasPort(s string) bool { + return strings.LastIndex(s, ":") > strings.LastIndex(s, "]") +} + +// RemoveEmptyPort 移除空端口 +func RemoveEmptyPort(host string) string { + if HasPort(host) { + return strings.TrimSuffix(host, ":") + } + return host +} + +// IsNotToken 检查字符是否不是有效的 HTTP token +func IsNotToken(r rune) bool { + return !httpguts.IsTokenRune(r) +} + +// ValidMethod 检查 HTTP 方法是否有效 +func ValidMethod(method string) bool { + return len(method) > 0 && strings.IndexFunc(method, IsNotToken) == -1 +} + +// ValueOrDefault 返回非空值,否则返回默认值 +func ValueOrDefault(value, def string) string { + if value != "" { + return value + } + return def +} + +// RequestMethodUsuallyLacksBody 检查 HTTP 方法是否通常不需要请求体 +func RequestMethodUsuallyLacksBody(method string) bool { + switch method { + case "GET", "HEAD", "DELETE", "OPTIONS", "PROPFIND", "SEARCH": + return true + } + return false +} + +// GetBodyReader 获取请求体读取器 +func GetBodyReader(rawBody any) (r result.Result[[]byte]) { + switch body := rawBody.(type) { + case nil: + return r + case *bytes.Buffer: + return r.WithValue(body.Bytes()) + case []byte: + return r.WithValue(body) + case string: + return r.WithValue(convert.StoB(body)) + // We prioritize *bytes.Reader here because we don't really want to + // deal with it seeking so want it to match here instead of the + // io.ReadSeeker case. + case *bytes.Reader: + buf, err := io.ReadAll(body) + if err != nil { + return r.WithErr(err) + } + return r.WithValue(buf) + // Compat case + case io.ReadSeeker: + _, err := body.Seek(0, 0) + if err != nil { + return r.WithErr(err) + } + + buf, err := io.ReadAll(body) + if err != nil { + return r.WithErr(err) + } + return r.WithValue(buf) + case url.Values: + return r.WithValue(convert.StoB(body.Encode())) + // Read all in so we can reset + case io.Reader: + buf, err := io.ReadAll(body) + if err != nil { + return r.WithErr(err) + } + return r.WithValue(buf) + case json.Marshaler: + return result.Wrap(body.MarshalJSON()) + default: + return result.Wrap(json.Marshal(rawBody)) + } +} + +// CloseBody 关闭请求体 +func CloseBody(r *http.Request) error { + if r.Body == nil { + return nil + } + return r.Body.Close() +} + +// ErrMissingHost 当请求中没有 Host 或 URL 时返回的错误 +var ErrMissingHost = errors.New("http: Request.Write on Request with no Host or URL set") + +// ReqWriteExcludeHeader Request.Write 自己处理的头,应该被跳过 +var ReqWriteExcludeHeader = map[string]bool{ + "Host": true, // not in Header map anyway + "User-Agent": true, + "Content-Length": true, + "Transfer-Encoding": true, + "Trailer": true, +} + +// HandleContentType 处理内容类型 +func HandleContentType(defaultContentType, configContentType, reqContentType string) (string, error) { + contentType := defaultContentType + if configContentType != "" { + contentType = configContentType + } + + if reqContentType != "" { + contentType = reqContentType + } + + if contentType == "" { + return "", errors.New("content-type header is empty") + } + + return contentType, nil +} + +// CreatePathTemplate 创建路径模板 +func CreatePathTemplate(path string) (*fasttemplate.Template, error) { + return fasttemplate.NewTemplate(path, "{", "}") +} + +// IsPathTemplate 检查路径是否包含模板参数 +func IsPathTemplate(path string) bool { + regParam := regexp.MustCompile(`{.+}`) + return regParam.MatchString(path) +} diff --git a/clients/resty/config.go b/clients/resty/config.go index 4a9a7e63f..b0709101f 100644 --- a/clients/resty/config.go +++ b/clients/resty/config.go @@ -11,29 +11,32 @@ import ( "golang.org/x/net/http/httpproxy" ) +// Config 客户端配置结构 type Config struct { - BaseUrl string `yaml:"base_url"` - ServiceName string `yaml:"service_name"` - DefaultHeader map[string]string `yaml:"default_header"` - DefaultContentType string `yaml:"default_content_type"` - DefaultRetryCount uint32 `yaml:"default_retry_count"` - DefaultRetryInterval time.Duration `yaml:"default_retry_interval"` - BasicToken string `yaml:"basic_token"` - JwtToken string `yaml:"jwt_token"` + BaseUrl string `yaml:"base_url"` // 基础 URL + ServiceName string `yaml:"service_name"` // 服务名称 + DefaultHeader map[string]string `yaml:"default_header"` // 默认请求头 + DefaultContentType string `yaml:"default_content_type"` // 默认内容类型 + DefaultRetryCount uint32 `yaml:"default_retry_count"` // 默认重试次数 + DefaultRetryInterval time.Duration `yaml:"default_retry_interval"` // 默认重试间隔 + BasicToken string `yaml:"basic_token"` // Basic 认证令牌 + JwtToken string `yaml:"jwt_token"` // JWT 认证令牌 - EnableProxy bool `yaml:"enable_proxy"` - EnableAuth bool `yaml:"enable_auth"` - DialTimeout time.Duration `yaml:"dial_timeout"` - ReadTimeout time.Duration `yaml:"read_timeout"` - WriteTimeout time.Duration `yaml:"write_timeout"` - MaxConnsPerHost int `yaml:"max_conns_per_host"` - MaxIdleConnDuration time.Duration `yaml:"max_idle_conn_duration"` - MaxIdemponentCallAttempts int `yaml:"max_idemponent_call_attempts"` - ReadBufferSize int `yaml:"read_buffer_size"` - WriteBufferSize int `yaml:"write_buffer_size"` - MaxResponseBodySize int `yaml:"max_response_body_size"` + EnableProxy bool `yaml:"enable_proxy"` // 是否启用代理 + EnableAuth bool `yaml:"enable_auth"` // 是否启用认证 + DialTimeout time.Duration `yaml:"dial_timeout"` // 拨号超时 + ReadTimeout time.Duration `yaml:"read_timeout"` // 读取超时 + WriteTimeout time.Duration `yaml:"write_timeout"` // 写入超时 + MaxConnsPerHost int `yaml:"max_conns_per_host"` // 每个主机的最大连接数 + MaxIdleConnDuration time.Duration `yaml:"max_idle_conn_duration"` // 最大空闲连接时长 + MaxIdemponentCallAttempts int `yaml:"max_idemponent_call_attempts"` // 最大幂等调用尝试次数 + ReadBufferSize int `yaml:"read_buffer_size"` // 读取缓冲区大小 + WriteBufferSize int `yaml:"write_buffer_size"` // 写入缓冲区大小 + MaxResponseBodySize int `yaml:"max_response_body_size"` // 最大响应体大小 } +// Build 构建 fasthttp 客户端 +// 返回: 构建后的 fasthttp 客户端实例 func (t *Config) Build() *fasthttp.Client { client := &fasthttp.Client{ Name: fmt.Sprintf("%s: %s", version.Project(), version.Version()), @@ -59,6 +62,8 @@ func (t *Config) Build() *fasthttp.Client { return client } +// DefaultCfg 获取默认配置 +// 返回: 默认配置实例 func DefaultCfg() *Config { return &Config{ DialTimeout: defaultHTTPTimeout, diff --git a/clients/resty/examples/main.go b/clients/resty/examples/main.go new file mode 100644 index 000000000..ae20580fe --- /dev/null +++ b/clients/resty/examples/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/pubgo/funk/v2/log" + "github.com/uber-go/tally/v4" + + "github.com/pubgo/lava/v2/clients/resty" + "github.com/pubgo/lava/v2/core/metrics" + "github.com/pubgo/lava/v2/lava" +) + +func main() { + // 初始化日志和指标 + logger := log.GetLogger() + metric := tally.NoopScope + + // 示例 1: 基本用法 + basicExample(logger, metric) + + // 示例 2: 带中间件的高级用法 + advancedExample(logger, metric) + + // 示例 3: 带认证的用法 + authExample(logger, metric) + + // 示例 4: 带重试的用法 + retryExample(logger, metric) +} + +// 基本用法示例 +func basicExample(logger log.Logger, metric metrics.Metric) { + fmt.Println("=== 基本用法示例 ===") + + // 创建客户端 + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + }, resty.Params{ + Log: logger, + Metric: metric, + }) + + // 创建请求 + req := resty.NewRequest(&resty.RequestSpec{ + Path: "/users/{id}", + Method: http.MethodGet, + }).SetParam("id", "123").SetQuery(map[string]string{ + "name": "test", + }) + + // 发送请求 + resp := client.Do(context.Background(), req).Unwrap() + logger.Info().Int("status", resp.StatusCode()).Msg("请求成功") + + // 示例:使用 JSON 方法反序列化响应 + type User struct { + ID string `json:"id"` + Name string `json:"name"` + } + var user User + if err := resp.JSON(&user); err != nil { + logger.Error().Err(err).Msg("JSON 反序列化失败") + } else { + logger.Info().Str("user", user.Name).Msg("JSON 反序列化成功") + } +} + +// 高级用法示例(带中间件) +func advancedExample(logger log.Logger, metric metrics.Metric) { + fmt.Println("\n=== 高级用法示例(带中间件)===") + + // 自定义中间件 + customMiddleware := lava.WithMiddleware("custom", func(next lava.HandlerFunc) lava.HandlerFunc { + return func(ctx context.Context, req lava.Request) (lava.Response, error) { + // 前置处理 + logger.Info().Str("endpoint", req.Endpoint()).Msg("请求开始") + + // 调用下一个中间件 + resp, err := next(ctx, req) + + // 后置处理 + if err != nil { + logger.Error().Err(err).Msg("请求失败") + } else { + logger.Info().Msg("请求结束") + } + + return resp, err + } + }) + + // 创建带自定义中间件的客户端 + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + }, resty.Params{ + Log: logger, + Metric: metric, + }, customMiddleware) + + // 创建请求 + req := resty.NewRequest(&resty.RequestSpec{ + Path: "/users", + Method: http.MethodPost, + }) + + // 发送请求 + resp := client.Do(context.Background(), req).Unwrap() + logger.Info().Int("status", resp.StatusCode()).Msg("请求成功") +} + +// 带认证的用法示例 +func authExample(logger log.Logger, metric metrics.Metric) { + fmt.Println("\n=== 带认证的用法示例 ===") + + // 创建带认证的客户端 + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + EnableAuth: true, + JwtToken: "your-jwt-token", + }, resty.Params{ + Log: logger, + Metric: metric, + }) + + // 创建请求 + req := resty.NewRequest(&resty.RequestSpec{ + Path: "/protected", + Method: http.MethodGet, + }) + + // 发送请求 + resp := client.Do(context.Background(), req).Unwrap() + logger.Info().Int("status", resp.StatusCode()).Msg("请求成功") +} + +// 带重试的用法示例 +func retryExample(logger log.Logger, metric metrics.Metric) { + fmt.Println("\n=== 带重试的用法示例 ===") + + // 创建带重试的客户端 + client := resty.New(&resty.Config{ + BaseUrl: "https://api.example.com", + ServiceName: "example-api", + DefaultRetryCount: 3, + DefaultRetryInterval: 100 * time.Millisecond, + }, resty.Params{ + Log: logger, + Metric: metric, + }) + + // 创建请求 + req := resty.NewRequest(&resty.RequestSpec{ + Path: "/flaky", + Method: http.MethodGet, + }) + + // 发送请求 + resp := client.Do(context.Background(), req).Unwrap() + logger.Info().Int("status", resp.StatusCode()).Msg("请求成功") +} diff --git a/clients/resty/jar.go b/clients/resty/jar.go index abd62c9bb..20b4b99c4 100644 --- a/clients/resty/jar.go +++ b/clients/resty/jar.go @@ -32,19 +32,25 @@ type Jar struct { func (j *Jar) Middleware(next lava.HandlerFunc) lava.HandlerFunc { return func(ctx context.Context, req lava.Request) (lava.Response, error) { + j.mu.Lock() + j.cleanExpired() for _, c := range j.cookies { req.Header().SetCookieBytesKV(c.Key(), c.Value()) } + j.mu.Unlock() rsp, err := next(ctx, req) if err != nil { return nil, err } + j.mu.Lock() + defer j.mu.Unlock() for _, value := range rsp.Header().All() { acquireCookie := fasthttp.AcquireCookie() if err := acquireCookie.ParseBytes(value); err != nil { j.log.Err(err, ctx).Msg("failed to parse cookie") + fasthttp.ReleaseCookie(acquireCookie) } else { j.cookies[string(acquireCookie.Key())] = acquireCookie } @@ -70,9 +76,28 @@ func (j *Jar) PeekValue(key string) []byte { func (j *Jar) Peek(key string) *fasthttp.Cookie { j.mu.Lock() defer j.mu.Unlock() + j.cleanExpired() return j.cookies[key] } +// CleanExpired 清理过期的 cookie +func (j *Jar) CleanExpired() { + j.mu.Lock() + defer j.mu.Unlock() + j.cleanExpired() +} + +// cleanExpired 内部方法,清理过期的 cookie +func (j *Jar) cleanExpired() { + now := time.Now() + for key, cookie := range j.cookies { + if cookie.Expire().Before(now) { + fasthttp.ReleaseCookie(cookie) + delete(j.cookies, key) + } + } +} + func (j *Jar) ReleaseCookie(key string) { j.mu.Lock() defer j.mu.Unlock() @@ -103,7 +128,9 @@ func (j *Jar) UnmarshalJSON(data []byte) error { return err } - return err + j.mu.Lock() + defer j.mu.Unlock() + return j.decode(cooks) } func (j *Jar) EncodeGOB() ([]byte, error) { diff --git a/clients/resty/middleware.go b/clients/resty/middleware.go index 52ed25ed9..40d4bf076 100644 --- a/clients/resty/middleware.go +++ b/clients/resty/middleware.go @@ -3,7 +3,7 @@ package resty import ( "fmt" - "github.com/gofiber/utils" + "github.com/gofiber/utils/v2" "github.com/pubgo/funk/v2/convert" "github.com/valyala/fasthttp" diff --git a/clients/resty/request.go b/clients/resty/request.go index d0dd32412..ef71532fe 100644 --- a/clients/resty/request.go +++ b/clients/resty/request.go @@ -3,25 +3,28 @@ package resty import ( "net/http" "net/url" - "regexp" "github.com/pubgo/funk/v2/retry" "github.com/valyala/fasthttp" ) -var regParam = regexp.MustCompile(`{.+}`) - +// RequestSpec 请求规范结构 type RequestSpec struct { - Header map[string]string - Path string - Method string - ContentType string - Backoff retry.Backoff - EnableAuth bool + Header map[string]string // 请求头 + Path string // 请求路径 + Method string // 请求方法 + ContentType string // 内容类型 + Backoff retry.Backoff // 重试策略 + EnableAuth bool // 是否启用认证 } +// CreateRequest 创建请求对象 +// 返回: 请求对象实例 func (r RequestSpec) CreateRequest() *Request { return NewRequest(&r) } +// NewRequest 创建新的请求对象 +// cfg: 请求规范 +// 返回: 请求对象实例 func NewRequest(cfg *RequestSpec) *Request { r := &Request{ cfg: cfg, @@ -32,33 +35,58 @@ func NewRequest(cfg *RequestSpec) *Request { return r } +// Request 请求对象 type Request struct { - req *fasthttp.Request - cfg *RequestSpec - header http.Header - query url.Values - params map[string]any - operation string - contentType string - body any - backoff retry.Backoff + req *fasthttp.Request // fasthttp 请求对象 + cfg *RequestSpec // 请求规范 + header http.Header // 请求头 + query url.Values // 查询参数 + params map[string]any // 路径参数 + operation string // 操作名称 + contentType string // 内容类型 + body any // 请求体 + backoff retry.Backoff // 重试策略 } +// SetBackoff 设置重试策略 +// backoff: 重试策略 +// 返回: 请求对象本身(链式调用) func (req *Request) SetBackoff(backoff retry.Backoff) *Request { req.backoff = backoff return req } +// SetBody 设置请求体 +// body: 请求体 +// 返回: 请求对象本身(链式调用) func (req *Request) SetBody(body any) *Request { req.body = body return req } +// SetQuery 设置查询参数,会覆盖相同键已存在的值。 +// query: 查询参数映射。 +// 返回: 请求对象本身(链式调用)。 func (req *Request) SetQuery(query map[string]string) *Request { if len(query) == 0 { return req } + for k, v := range query { + req.query.Set(k, v) + } + + return req +} + +// AddQuery 添加查询参数,会在相同键下追加值而不是覆盖。 +// query: 要追加的查询参数映射。 +// 返回: 请求对象本身(链式调用)。 +func (req *Request) AddQuery(query map[string]string) *Request { + if len(query) == 0 { + return req + } + for k, v := range query { req.query.Add(k, v) } @@ -66,21 +94,36 @@ func (req *Request) SetQuery(query map[string]string) *Request { return req } +// AddHeader 添加请求头 +// key: 请求头键 +// value: 请求头值 +// 返回: 请求对象本身(链式调用) func (req *Request) AddHeader(key, value string) *Request { req.header.Add(key, value) return req } +// SetHeader 设置请求头 +// key: 请求头键 +// value: 请求头值 +// 返回: 请求对象本身(链式调用) func (req *Request) SetHeader(key, value string) *Request { req.header.Set(key, value) return req } +// SetParam 设置路径参数 +// key: 参数键 +// val: 参数值 +// 返回: 请求对象本身(链式调用) func (req *Request) SetParam(key, val string) *Request { req.params[key] = val return req } +// SetParams 设置多个路径参数 +// params: 路径参数映射 +// 返回: 请求对象本身(链式调用) func (req *Request) SetParams(params map[string]string) *Request { for k, v := range params { req.params[k] = v @@ -88,7 +131,10 @@ func (req *Request) SetParams(params map[string]string) *Request { return req } +// SetContentType 设置内容类型 +// contentType: 内容类型 +// 返回: 请求对象本身(链式调用) func (req *Request) SetContentType(contentType string) *Request { - req.contentType = filterFlags(contentType) + req.contentType = FilterFlags(contentType) return req } diff --git a/clients/resty/request_test.go b/clients/resty/request_test.go index 9a49933e9..c99fc0ba3 100644 --- a/clients/resty/request_test.go +++ b/clients/resty/request_test.go @@ -7,10 +7,10 @@ import ( ) func TestParams(t *testing.T) { - assert.True(t, regParam.MatchString("/a/b/c/{a_b.c222}")) - assert.True(t, regParam.MatchString("/a/b/c{a_b.c222}")) - assert.True(t, regParam.MatchString("/a/b/c{ a_b.c222 }")) - assert.True(t, regParam.MatchString("/a/b/c{ a_b:c222/123 }")) + assert.True(t, IsPathTemplate("/a/b/c/{a_b.c222}")) + assert.True(t, IsPathTemplate("/a/b/c{a_b.c222}")) + assert.True(t, IsPathTemplate("/a/b/c{ a_b.c222 }")) + assert.True(t, IsPathTemplate("/a/b/c{ a_b:c222/123 }")) - assert.False(t, regParam.MatchString("/a/b/c")) + assert.False(t, IsPathTemplate("/a/b/c")) } diff --git a/clients/resty/response.go b/clients/resty/response.go new file mode 100644 index 000000000..f05035b52 --- /dev/null +++ b/clients/resty/response.go @@ -0,0 +1,36 @@ +package resty + +import ( + "github.com/goccy/go-json" + "github.com/valyala/fasthttp" +) + +// Response 封装了 fasthttp.Response,提供便捷的方法 +type Response struct { + resp *fasthttp.Response +} + +// StatusCode 返回响应状态码 +func (r *Response) StatusCode() int { + return r.resp.StatusCode() +} + +// Body 返回响应体 +func (r *Response) Body() []byte { + return r.resp.Body() +} + +// String 返回响应体的字符串表示 +func (r *Response) String() string { + return string(r.resp.Body()) +} + +// JSON 将响应体反序列化为 JSON +func (r *Response) JSON(v any) error { + return json.Unmarshal(r.resp.Body(), v) +} + +// Header 返回响应头 +func (r *Response) Header() *fasthttp.ResponseHeader { + return &r.resp.Header +} diff --git a/clients/resty/util.go b/clients/resty/util.go index 1d1eb2ae2..afeb46013 100644 --- a/clients/resty/util.go +++ b/clients/resty/util.go @@ -1,28 +1,19 @@ package resty import ( - "bytes" "context" - "errors" "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "github.com/goccy/go-json" - "github.com/pubgo/funk/v2/convert" "github.com/pubgo/funk/v2/result" "github.com/pubgo/funk/v2/retry" "github.com/valyala/fasthttp" "github.com/valyala/fasttemplate" - "golang.org/x/net/http/httpguts" "github.com/pubgo/lava/v2/lava" "github.com/pubgo/lava/v2/pkg/httputil" ) +// do 创建处理函数 func do(cfg *Config) lava.HandlerFunc { client := cfg.Build() return func(ctx context.Context, req lava.Request) (lava.Response, error) { @@ -33,6 +24,11 @@ func do(cfg *Config) lava.HandlerFunc { var err error resp := fasthttp.AcquireResponse() + backoff := retry.NewNoop() + if r.backoff != nil { + backoff = r.backoff + } + handle := func() error { deadline, ok := ctx.Deadline() if ok { @@ -42,13 +38,7 @@ func do(cfg *Config) lava.HandlerFunc { } return err } - - if r.backoff != nil { - err = retry.New(r.backoff).Do(func(i int) error { return handle() }) - } else { - err = handle() - } - + err = retry.Do(backoff, func(i int) error { return handle() }) if err != nil { return nil, err } @@ -57,68 +47,7 @@ func do(cfg *Config) lava.HandlerFunc { } } -func getBodyReader(rawBody any) (r result.Result[[]byte]) { - switch body := rawBody.(type) { - case nil: - return r - case *bytes.Buffer: - return r.WithValue(body.Bytes()) - case []byte: - return r.WithValue(body) - case string: - return r.WithValue(convert.StoB(body)) - - // We prioritize *bytes.Reader here because we don't really want to - // deal with it seeking so want it to match here instead of the - // io.ReadSeeker case. - case *bytes.Reader: - buf, err := io.ReadAll(body) - if err != nil { - return r.WithErr(err) - } - return r.WithValue(buf) - - // Compat case - case io.ReadSeeker: - _, err := body.Seek(0, 0) - if err != nil { - return r.WithErr(err) - } - - buf, err := io.ReadAll(body) - if err != nil { - return r.WithErr(err) - } - return r.WithValue(buf) - - case url.Values: - return r.WithValue(convert.StoB(body.Encode())) - - // Read all in so we can reset - case io.Reader: - buf, err := io.ReadAll(body) - if err != nil { - return r.WithErr(err) - } - return r.WithValue(buf) - - case json.Marshaler: - return result.Wrap(body.MarshalJSON()) - - default: - return result.Wrap(json.Marshal(rawBody)) - } -} - -// IsRedirect returns true if the status code indicates a redirect. -func IsRedirect(statusCode int) bool { - return statusCode == http.StatusMovedPermanently || - statusCode == http.StatusFound || - statusCode == http.StatusSeeOther || - statusCode == http.StatusTemporaryRedirect || - statusCode == http.StatusPermanentRedirect -} - +// handleHeader 处理请求头 func handleHeader(c *Client, req *Request) { header := c.cfg.DefaultHeader for k, v := range header { @@ -126,6 +55,7 @@ func handleHeader(c *Client, req *Request) { } } +// handlePath 处理请求路径 func handlePath(c *Client, req *Request) (r result.Result[string]) { reqConf := req.cfg @@ -133,47 +63,35 @@ func handlePath(c *Client, req *Request) (r result.Result[string]) { req.operation = reqUrl.Path if v, ok := c.pathTemplates.Load(reqUrl.Path); ok && v != nil { - return result.Wrap(pathTemplateRun(v.(*fasttemplate.Template), req.params)) - } else { - if regParam.MatchString(reqUrl.Path) { - pathTemplate, err := fasttemplate.NewTemplate(reqUrl.Path, "{", "}") - if err != nil { - return r.WithErr(err) - } - c.pathTemplates.Store(reqUrl.Path, pathTemplate) - } else { - return r.WithValue(reqUrl.Path) - } + return result.Wrap(PathTemplateRun(v.(*fasttemplate.Template), req.params)) } - return r + if IsPathTemplate(reqUrl.Path) { + pathTemplate, err := CreatePathTemplate(reqUrl.Path) + if err != nil { + return r.WithErr(fmt.Errorf("create path template error: %w", err)) + } + c.pathTemplates.Store(reqUrl.Path, pathTemplate) + return result.Wrap(PathTemplateRun(pathTemplate, req.params)) + } else { + return r.WithValue(reqUrl.Path) + } } +// handleContentType 处理内容类型 func handleContentType(c *Client, req *Request) (r result.Result[string]) { defaultConf := c.cfg reqConf := req.cfg - contentType := defaultContentType + defaultContentTypeValue := defaultContentType if defaultConf.DefaultContentType != "" { - contentType = defaultConf.DefaultContentType - } - - if reqConf.ContentType != "" { - contentType = reqConf.ContentType - } - - if req.contentType != "" { - contentType = req.contentType - } - - if contentType == "" { - return r.WithErr(errors.New("content-type header is empty")) + defaultContentTypeValue = defaultConf.DefaultContentType } - return r.WithValue(contentType) + return result.Wrap(HandleContentType(defaultContentTypeValue, reqConf.ContentType, req.contentType)) } -// doRequest data:[bytes|string|map|struct] +// doRequest 构建请求 func doRequest(c *Client, req *Request) (rsp result.Result[*fasthttp.Request]) { r := fasthttp.AcquireRequest() @@ -192,7 +110,7 @@ func doRequest(c *Client, req *Request) (rsp result.Result[*fasthttp.Request]) { r.Header.SetMethod(mth) - if getBodyReader(req.body). + if GetBodyReader(req.body). IfOK(func(val []byte) { r.SetBodyRaw(val) }). @@ -210,12 +128,10 @@ func doRequest(c *Client, req *Request) (rsp result.Result[*fasthttp.Request]) { // enable auth if c.cfg.EnableAuth || req.cfg.EnableAuth { - if c.cfg.BasicToken != "" { - r.Header.Set(httputil.HeaderAuthorization, "Basic "+c.cfg.BasicToken) - } - if c.cfg.JwtToken != "" { r.Header.Set(httputil.HeaderAuthorization, "Bearer "+c.cfg.JwtToken) + } else if c.cfg.BasicToken != "" { + r.Header.Set(httputil.HeaderAuthorization, "Basic "+c.cfg.BasicToken) } } @@ -248,133 +164,3 @@ func doRequest(c *Client, req *Request) (rsp result.Result[*fasthttp.Request]) { return rsp.WithValue(r) } - -func filterFlags(content string) string { - for i, char := range content { - if char == ' ' || char == ';' { - return content[:i] - } - } - return content -} - -func toString(v any) string { - switch t := v.(type) { - case string: - return t - case bool: - return strconv.FormatBool(t) - case int: - return strconv.Itoa(t) - case int8: - return strconv.FormatInt(int64(t), 10) - case int16: - return strconv.FormatInt(int64(t), 10) - case int32: - return strconv.FormatInt(int64(t), 10) - case int64: - return strconv.FormatInt(t, 10) - case uint: - return strconv.FormatUint(uint64(t), 10) - case uint8: - return strconv.FormatUint(uint64(t), 10) - case uint16: - return strconv.FormatUint(uint64(t), 10) - case uint32: - return strconv.FormatUint(uint64(t), 10) - case uint64: - return strconv.FormatUint(t, 10) - default: - return fmt.Sprintf("%v", t) - } -} - -func pathTemplateRun(tpl *fasttemplate.Template, params map[string]any) (string, error) { - return tpl.ExecuteFuncStringWithErr(func(w io.Writer, tag string) (int, error) { - return w.Write(convert.StoB(toString(params[tag]))) - }) -} - -// get is like Get, but key must already be in CanonicalHeaderKey form. -func headerGet(h http.Header, key string) string { - if v := h[key]; len(v) > 0 { - return v[0] - } - return "" -} - -// has reports whether h has the provided key defined, even if it's -// set to 0-length slice. -func headerHas(h http.Header, key string) bool { - _, ok := h[key] - return ok -} - -// Given a string of the form "host", "host:port", or "[ipv6::address]:port", -// return true if the string includes a port. -func hasPort(s string) bool { return strings.LastIndex(s, ":") > strings.LastIndex(s, "]") } - -// removeEmptyPort strips the empty port in ":port" to "" -// as mandated by RFC 3986 Section 6.2.3. -func removeEmptyPort(host string) string { - if hasPort(host) { - return strings.TrimSuffix(host, ":") - } - return host -} - -func isNotToken(r rune) bool { - return !httpguts.IsTokenRune(r) -} - -func validMethod(method string) bool { - return len(method) > 0 && strings.IndexFunc(method, isNotToken) == -1 -} - -func closeBody(r *http.Request) error { - if r.Body == nil { - return nil - } - return r.Body.Close() -} - -// requestBodyReadError wraps an error from (*Request).write to indicate -// that the error came from a Read call on the Request.Body. -// This error type should not escape the net/http package to users. -type requestBodyReadError struct{ error } - -// Return value if nonempty, def otherwise. -func valueOrDefault(value, def string) string { - if value != "" { - return value - } - return def -} - -// errMissingHost is returned by Write when there is no Host or URL present in -// the Request. -var errMissingHost = errors.New("http: Request.Write on Request with no Host or URL set") - -// Headers that Request.Write handles itself and should be skipped. -var reqWriteExcludeHeader = map[string]bool{ - "Host": true, // not in Header map anyway - "User-Agent": true, - "Content-Length": true, - "Transfer-Encoding": true, - "Trailer": true, -} - -// requestMethodUsuallyLacksBody reports whether the given request -// method is one that typically does not involve a request body. -// This is used by the Transport (via -// transferWriter.shouldSendChunkedRequestBody) to determine whether -// we try to test-read a byte from a non-nil Request.Body when -// Request.outgoingLength() returns -1. See the comments in -// shouldSendChunkedRequestBody. -func requestMethodUsuallyLacksBody(method string) bool { - switch method { - case "GET", "HEAD", "DELETE", "OPTIONS", "PROPFIND", "SEARCH": - return true - } - return false -} diff --git a/cmds/configcmd/cmd.go b/cmds/configcmd/cmd.go new file mode 100644 index 000000000..f3a29b0c7 --- /dev/null +++ b/cmds/configcmd/cmd.go @@ -0,0 +1,31 @@ +package configcmd + +import ( + "context" + "fmt" + + "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/redant" + yaml "gopkg.in/yaml.v3" +) + +func New[Cfg any]() *redant.Command { + return &redant.Command{ + Use: "config", + Short: "config management", + Children: []*redant.Command{ + { + Use: "show", + Short: "show config data", + Handler: func(ctx context.Context, i *redant.Invocation) error { + defer recovery.Exit() + fmt.Println("config path:\n", config.GetConfigPath()) + fmt.Println("config raw data:\n", string(assert.Must1(yaml.Marshal(config.Load[Cfg]().T)))) + return nil + }, + }, + }, + } +} diff --git a/cmds/curlcmd/cmd.go b/cmds/curlcmd/cmd.go new file mode 100644 index 000000000..47b3c080c --- /dev/null +++ b/cmds/curlcmd/cmd.go @@ -0,0 +1,576 @@ +package curlcmd + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/errors" + "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/redant" + + "github.com/pubgo/lava/v2/core/running" + "github.com/pubgo/lava/v2/pkg/cliutil" +) + +type kvFlag struct { + data map[string]string +} + +func (k *kvFlag) Set(v string) error { + parts := strings.SplitN(v, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid key=value pair: %s", v) + } + + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if key == "" { + return fmt.Errorf("invalid key in pair: %s", v) + } + + if k.data == nil { + k.data = make(map[string]string) + } + k.data[key] = val + return nil +} + +func (k *kvFlag) String() string { + if k == nil { + return "" + } + + keys := make([]string, 0, len(k.data)) + for key := range k.data { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", key, k.data[key])) + } + return strings.Join(parts, ",") +} + +func (k *kvFlag) Map() map[string]string { + if k == nil { + return nil + } + return k.data +} + +func (k *kvFlag) Type() string { return "kv" } + +type gatewayVarInfo struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type routeOperation struct { + Method string `json:"method"` + Path string `json:"path"` + Operation string `json:"operation"` + Verb string `json:"verb"` + Vars []string `json:"vars"` + Extras map[string]any `json:"extras"` +} + +type gatewayInfo struct { + Method []routeOperation `json:"method"` +} + +// New returns curl command which provides a lightweight HTTP client for gateway APIs. +func New() *redant.Command { + defaultAddr := fmt.Sprintf("http://127.0.0.1:%d", running.HttpPort.Value()) + + var ( + addr = defaultAddr + prefix = "/api" + opFlag string + pathFlag string + methodOverride string + data string + dataFile string + readStdin bool + listOnly bool + varName = "grpc-server-info" + timeout = 15 * time.Second + insecure bool + noPretty bool + + headers = &kvFlag{} + queries = &kvFlag{} + params = &kvFlag{} + ) + + cmd := &redant.Command{ + Use: "curl [flags] ", + Short: cliutil.UsageDesc("%s gateway curl helper", version.Project()), + Options: redant.OptionSet{ + {Flag: "addr", Description: "gateway http address, e.g. http://127.0.0.1:8080", Default: addr, Value: redant.StringOf(&addr)}, + {Flag: "prefix", Description: "gateway prefix path", Default: prefix, Value: redant.StringOf(&prefix)}, + {Flag: "operation", Description: "operation name (grpc full method or custom name)", Value: redant.StringOf(&opFlag)}, + {Flag: "path", Description: "explicit request path, overrides operation", Value: redant.StringOf(&pathFlag)}, + {Flag: "method", Shorthand: "X", Description: "http method override", Value: redant.StringOf(&methodOverride)}, + {Flag: "data", Shorthand: "d", Description: "request body string", Value: redant.StringOf(&data)}, + {Flag: "data-file", Description: "path to file used as request body", Value: redant.StringOf(&dataFile)}, + {Flag: "stdin", Description: "read request body from stdin", Value: redant.BoolOf(&readStdin)}, + {Flag: "list", Description: "list registered gateway routes and exit", Value: redant.BoolOf(&listOnly)}, + {Flag: "vars-name", Description: "expvar name that stores gateway info", Default: varName, Value: redant.StringOf(&varName)}, + {Flag: "timeout", Description: "request timeout, e.g. 5s or 2m", Default: timeout.String(), Value: redant.DurationOf(&timeout)}, + {Flag: "insecure", Shorthand: "k", Description: "allow insecure TLS connections", Value: redant.BoolOf(&insecure)}, + {Flag: "raw", Description: "print response body as-is without JSON pretty format", Value: redant.BoolOf(&noPretty)}, + {Flag: "header", Shorthand: "H", Description: "set request header, key=value (repeatable)", Value: headers}, + {Flag: "query", Shorthand: "Q", Description: "set query parameter, key=value (repeatable)", Value: queries}, + {Flag: "param", Shorthand: "P", Description: "set path parameter, key=value (repeatable)", Value: params}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + defer recovery.Exit() + + remaining := inv.Args + target := "" + if len(remaining) > 0 { + target = remaining[0] + } + if pathFlag != "" { + target = pathFlag + } + if opFlag != "" { + target = opFlag + } + + client := &http.Client{Timeout: timeout} + if insecure { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + client.Transport = tr + } + + ctxReq, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + routes := make([]routeOperation, 0) + needRoutes := listOnly || (target != "" && !strings.HasPrefix(target, "/")) + if needRoutes { + rts, err := fetchGatewayRoutes(ctxReq, client, addr, varName) + if err != nil { + return err + } + routes = rts + } + + if listOnly { + if err := printRoutes(os.Stdout, routes, prefix); err != nil { + return errors.Wrap(err, "failed to print routes") + } + return nil + } + + if target == "" { + return errors.New("operation or path is required") + } + + var matched *routeOperation + if !strings.HasPrefix(target, "/") { + for idx := range routes { + if routes[idx].Operation == target { + matched = &routes[idx] + break + } + } + if matched == nil { + return errors.Errorf("operation %q not found, use --list to inspect", target) + } + } + + method := methodOverride + rawPath := target + if matched != nil { + method = normalizeMethod(matched.Method) + rawPath = matched.Path + } + if method == "" { + method = http.MethodGet + } + + fullPath := joinPath(prefix, rawPath) + filledPath, err := applyPathParams(fullPath, params.Map()) + if err != nil { + return err + } + fullPath = filledPath + + fullURL := strings.TrimRight(addr, "/") + fullPath + reqBody, err := buildRequestBody(data, dataFile, readStdin) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctxReq, method, fullURL, reqBody) + if err != nil { + return errors.Wrap(err, "failed to build request") + } + + if q := queries.Map(); len(q) > 0 { + query := req.URL.Query() + for k, v := range q { + query.Set(k, v) + } + req.URL.RawQuery = query.Encode() + } + + if h := headers.Map(); len(h) > 0 { + for k, v := range h { + req.Header.Set(k, v) + } + } + + // auto inject token if present and Authorization not set + if req.Header.Get("Authorization") == "" { + if token, _ := loadToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + } + + if req.Body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "application/json") + } + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + return errors.Wrap(err, "request failed") + } + + elapsed := time.Since(start) + bodyBytes, err := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err != nil { + return errors.Wrap(err, "failed to read response body") + } + if closeErr != nil { + return errors.Wrap(closeErr, "failed to close response body") + } + + if _, err := fmt.Fprintf(os.Stdout, "=> %s %s\n", method, req.URL.String()); err != nil { + return errors.Wrap(err, "failed to write request line") + } + if _, err := fmt.Fprintf(os.Stdout, "<= %d %s (%s)\n", resp.StatusCode, http.StatusText(resp.StatusCode), elapsed); err != nil { + return errors.Wrap(err, "failed to write response line") + } + if err := printHeaders(os.Stdout, resp.Header); err != nil { + return errors.Wrap(err, "failed to write headers") + } + if len(bodyBytes) > 0 { + if _, err := fmt.Fprintln(os.Stdout); err != nil { + return errors.Wrap(err, "failed to write newline") + } + if !noPretty && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "json") { + var buf bytes.Buffer + if err := json.Indent(&buf, bodyBytes, "", " "); err == nil { + _, _ = buf.WriteTo(os.Stdout) + if buf.Len() == 0 || bodyBytes[len(bodyBytes)-1] != '\n' { + if _, err := fmt.Fprintln(os.Stdout); err != nil { + return errors.Wrap(err, "failed to write newline") + } + } + return nil + } + } + if _, err := os.Stdout.Write(bodyBytes); err != nil { + return errors.Wrap(err, "failed to write body") + } + if bodyBytes[len(bodyBytes)-1] != '\n' { + if _, err := fmt.Fprintln(os.Stdout); err != nil { + return errors.Wrap(err, "failed to write newline") + } + } + } + + return nil + }, + } + + cmd.Children = append(cmd.Children, newLoginCommand()) + + return cmd +} + +func printHeaders(w io.Writer, header http.Header) error { + if len(header) == 0 { + return nil + } + + keys := make([]string, 0, len(header)) + for k := range header { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if _, err := fmt.Fprintf(w, "%s: %s\n", k, strings.Join(header[k], ", ")); err != nil { + return err + } + } + return nil +} + +func buildRequestBody(body, file string, readStdin bool) (io.ReadCloser, error) { + switch { + case file != "": + b, err := os.ReadFile(file) + if err != nil { + return nil, errors.Wrapf(err, "failed to read body file %s", file) + } + return io.NopCloser(bytes.NewReader(b)), nil + case readStdin: + data, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, errors.Wrap(err, "failed to read stdin") + } + return io.NopCloser(bytes.NewReader(data)), nil + case body != "": + return io.NopCloser(strings.NewReader(body)), nil + default: + return nil, nil + } +} + +func fetchGatewayRoutes(ctx context.Context, client *http.Client, addr, prefer string) ([]routeOperation, error) { + listURL := strings.TrimRight(addr, "/") + "/debug/vars/api/list" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to build vars list request") + } + + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to request vars list") + } + defer func() { _ = resp.Body.Close() }() + + var varsResp []gatewayVarInfo + if err := json.NewDecoder(resp.Body).Decode(&varsResp); err != nil { + return nil, errors.Wrap(err, "failed to decode vars list") + } + + varName := "" + if prefer != "" { + for _, v := range varsResp { + if v.Name == prefer { + varName = v.Name + break + } + } + } + if varName == "" { + for _, v := range varsResp { + if strings.Contains(strings.ToLower(v.Name), "grpc-server-info") { + varName = v.Name + break + } + } + } + if varName == "" && prefer != "" { + for _, v := range varsResp { + if strings.Contains(strings.ToLower(v.Name), strings.ToLower(prefer)) { + varName = v.Name + break + } + } + } + + if varName == "" { + names := make([]string, 0, len(varsResp)) + for _, v := range varsResp { + names = append(names, v.Name) + } + return nil, errors.Errorf("gateway vars not found (prefer=%s), available: %v", prefer, names) + } + + detailURL := strings.TrimRight(addr, "/") + "/debug/vars/api/get/" + url.PathEscape(varName) + req, err = http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to build vars get request") + } + + resp, err = client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to request gateway info") + } + defer func() { _ = resp.Body.Close() }() + + var info gatewayInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, errors.Wrap(err, "failed to decode gateway info") + } + + return info.Method, nil +} + +func printRoutes(w io.Writer, routes []routeOperation, prefix string) error { + if len(routes) == 0 { + if _, err := fmt.Fprintln(w, "no gateway routes found"); err != nil { + return err + } + return nil + } + + if _, err := fmt.Fprintf(w, "Registered gateway routes (%d):\n", len(routes)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "METHOD\tPATH\tOPERATION\tVERB\n"); err != nil { + return err + } + for _, r := range routes { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", normalizeMethod(r.Method), joinPath(prefix, r.Path), r.Operation, r.Verb); err != nil { + return err + } + } + return nil +} + +var pathPlaceholderRe = regexp.MustCompile(`\{[^}]+\}`) + +func applyPathParams(path string, params map[string]string) (string, error) { + placeholders := pathPlaceholderRe.FindAllString(path, -1) + for _, ph := range placeholders { + key := strings.Trim(ph, "{}") + if idx := strings.IndexByte(key, '='); idx >= 0 { + key = key[:idx] + } + val, ok := params[key] + if !ok { + val, ok = params[strings.Trim(ph, "{}")] + } + if !ok { + return "", errors.Errorf("missing path param for %s", ph) + } + path = strings.ReplaceAll(path, ph, url.PathEscape(val)) + } + return path, nil +} + +func joinPath(prefix, path string) string { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + prefix = strings.TrimSpace(prefix) + if prefix == "" || prefix == "/" { + return path + } + + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + prefix = strings.TrimSuffix(prefix, "/") + return prefix + path +} + +func normalizeMethod(method string) string { + if strings.HasPrefix(method, "__") && strings.HasSuffix(method, "__") { + method = strings.TrimSuffix(strings.TrimPrefix(method, "__"), "__") + } + return strings.ToUpper(method) +} + +// token helpers +func tokenFilePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".lava", "token") +} + +func loadToken() (string, error) { + path := tokenFilePath() + if path == "" { + return "", errors.New("cannot resolve home dir for token") + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", errors.Wrap(err, "read token") + } + return strings.TrimSpace(string(b)), nil +} + +func saveToken(tok string) error { + path := tokenFilePath() + if path == "" { + return errors.New("cannot resolve home dir for token") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return errors.Wrap(err, "mkdir token dir") + } + return errors.Wrap(os.WriteFile(path, []byte(strings.TrimSpace(tok)), 0o600), "write token") +} + +func newLoginCommand() *redant.Command { + var ( + token string + stdin bool + fromEnv bool + ) + + return &redant.Command{ + Use: "login", + Short: "Save authorization token for subsequent requests", + Options: redant.OptionSet{ + {Flag: "token", Shorthand: "t", Description: "token string (fallback to stdin)", Value: redant.StringOf(&token)}, + {Flag: "stdin", Description: "read token from stdin", Value: redant.BoolOf(&stdin)}, + {Flag: "env", Description: "read token from LAVA_TOKEN env", Value: redant.BoolOf(&fromEnv)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + defer recovery.Exit() + + if fromEnv && token == "" { + token = os.Getenv("LAVA_TOKEN") + } + if token == "" && stdin { + b, err := io.ReadAll(inv.Stdin) + if err != nil { + return errors.Wrap(err, "read stdin token") + } + token = string(b) + } + if token == "" && len(inv.Args) > 0 { + token = inv.Args[0] + } + token = strings.TrimSpace(token) + if token == "" { + return errors.New("token is required") + } + + if err := saveToken(token); err != nil { + return err + } + + if _, err := fmt.Fprintln(inv.Stdout, "token saved"); err != nil { + return errors.Wrap(err, "failed to write token saved message") + } + return nil + }, + } +} diff --git a/cmds/depcmd/cmd.go b/cmds/depcmd/cmd.go index 18ce67a77..00ac136d6 100644 --- a/cmds/depcmd/cmd.go +++ b/cmds/depcmd/cmd.go @@ -12,9 +12,10 @@ import ( "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/pretty" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/redant" "github.com/samber/lo" + + "github.com/pubgo/lava/v2/core/running" ) func New(di *dix.Dix) *redant.Command { diff --git a/cmds/devproxycmd/cmd.go b/cmds/devproxycmd/cmd.go new file mode 100644 index 000000000..0c7e2c19b --- /dev/null +++ b/cmds/devproxycmd/cmd.go @@ -0,0 +1,57 @@ +package devproxycmd + +import ( + "context" + + "github.com/pubgo/dix/v2" + "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/redant" +) + +func New(di *dix.Dix) *redant.Command { + return &redant.Command{ + Use: "devproxy", + Short: "Local development proxy tool", + Long: "Local development proxy tool with DNS and HTTP routing", + Middleware: func(next redant.HandlerFunc) redant.HandlerFunc { + return func(ctx context.Context, inv *redant.Invocation) error { + assert.Must(loadConfig()) + + return next(ctx, inv) + } + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + return StartDevProxy(ctx) + }, + Children: []*redant.Command{ + { + Use: "start", + Short: "Start devproxy server", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return StartDevProxy(ctx) + }, + }, + { + Use: "install", + Short: "Install system integration", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return InstallSystemIntegration() + }, + }, + { + Use: "uninstall", + Short: "Uninstall system integration", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return UninstallSystemIntegration() + }, + }, + { + Use: "routes", + Short: "Show current routes", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return ShowRoutes() + }, + }, + }, + } +} diff --git a/cmds/devproxycmd/devproxy.go b/cmds/devproxycmd/devproxy.go new file mode 100644 index 000000000..f118d117f --- /dev/null +++ b/cmds/devproxycmd/devproxy.go @@ -0,0 +1,357 @@ +package devproxycmd + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/miekg/dns" + "github.com/pubgo/funk/v2/log" + "gopkg.in/yaml.v3" +) + +// Config 配置结构 +type Config struct { + DNS struct { + Port int `json:"port" yaml:"port"` + } `json:"dns" yaml:"dns"` + HTTP struct { + Port int `json:"port" yaml:"port"` + } `json:"http" yaml:"http"` + Routes []Route `json:"routes" yaml:"routes"` +} + +// Route 路由配置 +type Route struct { + Pattern string `json:"pattern" yaml:"pattern"` + Target string `json:"target" yaml:"target"` + Path string `json:"path" yaml:"path"` +} + +// 默认配置 +var defaultConfig = Config{ + DNS: struct { + Port int `json:"port" yaml:"port"` + }{ + Port: 5353, + }, + HTTP: struct { + Port int `json:"port" yaml:"port"` + }{ + Port: 8080, + }, + Routes: []Route{ + { + Pattern: "app", + Target: "localhost:3000", + }, + { + Pattern: "debug.*", + Target: "localhost:8080", + Path: "/debug", + }, + { + Pattern: "api.*", + Target: "localhost:8080", + Path: "/{wildcard}", + }, + }, +} + +// 全局配置 +var config Config + +// StartDevProxy 启动开发代理 +func StartDevProxy(ctx context.Context) error { + // 加载配置 + if err := loadConfig(); err != nil { + log.Error().Err(err).Msg("Failed to load config") + // 使用默认配置 + config = defaultConfig + } + + // 启动DNS服务 + go func() { + if err := startDNSServer(); err != nil { + log.Error().Err(err).Msg("Failed to start DNS server") + } + }() + + // 启动HTTP代理服务 + go func() { + if err := startHTTPServer(); err != nil { + log.Error().Err(err).Msg("Failed to start HTTP server") + } + }() + + log.Info().Msg("DevProxy started successfully") + log.Info().Msgf("DNS server listening on port %d", config.DNS.Port) + log.Info().Msgf("HTTP proxy listening on port %d", config.HTTP.Port) + + // 等待终止信号 + <-ctx.Done() + log.Info().Msg("DevProxy shutting down") + return nil +} + +// startDNSServer 启动DNS服务器 +func startDNSServer() error { + // 创建DNS服务器 + server := &dns.Server{ + Addr: fmt.Sprintf(":%d", config.DNS.Port), + Net: "udp", + } + + // 注册处理函数,处理所有lava域名 + dns.HandleFunc("lava.", handleDNSRequest) + + // 启动服务器 + log.Info().Msgf("Starting DNS server on port %d", config.DNS.Port) + return server.ListenAndServe() +} + +// handleDNSRequest 处理DNS请求 +func handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) { + m := &dns.Msg{} + m.SetReply(r) + m.Authoritative = true + + // 处理A记录查询 + for _, q := range r.Question { + if q.Qtype == dns.TypeA { + // 所有lava域名都解析到127.0.0.1 + record := &dns.A{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 3600, + }, + A: net.ParseIP("127.0.0.1"), + } + m.Answer = append(m.Answer, record) + } + } + + // 发送响应 + _ = w.WriteMsg(m) +} + +// startHTTPServer 启动HTTP代理服务器 +func startHTTPServer() error { + // 创建路由器 + mux := http.NewServeMux() + + // 健康检查端点 + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "version": "1.0.0", + "time": time.Now().Format(time.RFC3339), + }); err != nil { + log.Error().Err(err).Msg("Failed to encode health response") + } + }) + + // 代理处理 + mux.HandleFunc("/", handleHTTPRequest) + + // 创建HTTP服务器 + server := &http.Server{ + Addr: fmt.Sprintf(":%d", config.HTTP.Port), + Handler: mux, + } + + // 启动服务器 + log.Info().Msgf("Starting HTTP proxy on %s", server.Addr) + return server.ListenAndServe() +} + +// handleHTTPRequest 处理HTTP请求 +func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { + // 获取主机名 + host := r.Host + log.Debug().Str("host", host).Msg("Received request") + + // 移除.lava后缀 + subdomain := strings.TrimSuffix(host, ".lava") + + // 查找匹配的路由 + route, wildcard := matchRoute(subdomain) + if route == nil { + http.Error(w, "No route found", http.StatusNotFound) + return + } + + // 构建目标URL + targetURL := buildTargetURL(route, wildcard, r.URL.Path, r.URL.RawQuery) + log.Debug().Str("target", targetURL).Msg("Forwarding request") + + // 转发所有请求(包括WebSocket) + forwardRequest(w, r, targetURL) +} + +// matchRoute 匹配路由 +func matchRoute(subdomain string) (*Route, string) { + // 精确匹配 + for i := range config.Routes { + route := &config.Routes[i] + if route.Pattern == subdomain { + return route, "" + } + } + + // 通配符匹配(从最长到最短) + parts := strings.Split(subdomain, ".") + for i := 0; i < len(parts); i++ { + pattern := strings.Join(parts[i:], ".") + ".*" + for j := range config.Routes { + route := &config.Routes[j] + if route.Pattern == pattern { + wildcard := strings.Join(parts[:i], ".") + return route, wildcard + } + } + } + + // 检查是否有默认路由 + for i := range config.Routes { + route := &config.Routes[i] + if route.Pattern == "*" { + return route, subdomain + } + } + + return nil, "" +} + +// buildTargetURL 构建目标URL +func buildTargetURL(route *Route, wildcard, path, query string) string { + // 构建路径 + targetPath := route.Path + if targetPath == "" { + targetPath = path + } else { + // 替换占位符 + targetPath = strings.ReplaceAll(targetPath, "{wildcard}", wildcard) + } + + // 确保路径格式正确 + if !strings.HasPrefix(targetPath, "/") { + targetPath = "/" + targetPath + } + + // 处理路径,避免重复的路径前缀 + if path != "/" { + // 移除路径中可能存在的重复前缀 + trimmedPath := strings.TrimPrefix(path, targetPath) + if trimmedPath == path { + // 如果没有重复前缀,直接添加 + if strings.HasSuffix(targetPath, "/") { + targetPath += strings.TrimPrefix(path, "/") + } else { + targetPath += path + } + } else { + // 如果有重复前缀,使用修剪后的路径 + targetPath += trimmedPath + } + } + + // 构建完整URL + targetURL := "http://" + route.Target + targetPath + if query != "" { + targetURL += "?" + query + } + + return targetURL +} + +// forwardRequest 转发普通HTTP请求 +func forwardRequest(w http.ResponseWriter, r *http.Request, targetURL string) { + // 解析目标URL + target, err := url.Parse(targetURL) + if err != nil { + log.Error().Err(err).Str("target", targetURL).Msg("Failed to parse target URL") + http.Error(w, "Failed to parse target URL", http.StatusBadGateway) + return + } + + // 创建反向代理 + proxy := httputil.NewSingleHostReverseProxy(target) + + // 自定义错误处理 + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Error().Err(err).Str("target", targetURL).Msg("Failed to forward request") + http.Error(w, "Failed to forward request", http.StatusBadGateway) + } + + // 执行代理 + proxy.ServeHTTP(w, r) +} + +// loadConfig 加载配置 +func loadConfig() error { + // 尝试从多个位置加载配置 + configPaths := []string{ + ".devproxy.json", + ".devproxy.yaml", + ".devproxy.yml", + "~/.devproxy.json", + "~/.devproxy.yaml", + "~/.devproxy.yml", + } + + for _, path := range configPaths { + // 处理~路径 + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + continue + } + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + + // 检查文件是否存在 + if _, err := os.Stat(path); err == nil { + // 读取配置文件 + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // 根据文件扩展名解析配置 + ext := filepath.Ext(path) + switch ext { + case ".json": + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse JSON config: %w", err) + } + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse YAML config: %w", err) + } + default: + return fmt.Errorf("unsupported config file format: %s", ext) + } + + log.Info().Str("path", path).Msg("Loaded config from") + return nil + } + } + + // 如果没有找到配置文件,使用默认配置 + config = defaultConfig + log.Info().Msg("Using default config") + return nil +} diff --git a/cmds/devproxycmd/reverse_proxy_analysis.md b/cmds/devproxycmd/reverse_proxy_analysis.md new file mode 100644 index 000000000..6afd8c2e9 --- /dev/null +++ b/cmds/devproxycmd/reverse_proxy_analysis.md @@ -0,0 +1,63 @@ +# 反向代理实现分析:自定义实现 vs httputil.ReverseProxy + +## 当前实现分析 + +当前 devproxy 模块使用了自定义的反向代理实现,主要特点: + +1. **基于 Fiber 框架**:使用 Fiber 作为 HTTP 服务器,处理请求路由 +2. **手动请求转发**:通过 `forwardRequest` 函数手动实现 HTTP 请求转发 +3. **手动 WebSocket 处理**:通过 `forwardWebSocket` 函数手动处理 WebSocket 连接 +4. **DNS 服务器**:内置 DNS 服务器,将 .lava 域名解析到本地 +5. **路由匹配**:实现了基于子域名的路由匹配逻辑 +6. **配置管理**:支持 JSON/YAML 配置文件加载 + +## httputil.ReverseProxy 分析 + +Go 标准库 `net/http/httputil` 中的 `ReverseProxy` 实现: + +1. **标准库支持**:官方维护,稳定性高 +2. **功能完备**:内置请求转发、响应处理、错误处理 +3. **性能优化**:经过官方优化,处理并发请求更高效 +4. **自动处理**:自动处理请求头、响应头、请求体等 +5. **扩展性**:支持自定义 Director 函数修改请求 + +## 对比分析 + +| 特性 | 当前实现 | httputil.ReverseProxy | +|------|---------|----------------------| +| 实现复杂度 | 高(手动实现所有逻辑) | 低(使用标准库) | +| 维护成本 | 高(需要自己维护所有逻辑) | 低(标准库维护) | +| 稳定性 | 中(自定义实现可能有未覆盖的场景) | 高(经过广泛测试) | +| 性能 | 中(自定义实现) | 高(标准库优化) | +| 功能完整性 | 中(基本功能实现) | 高(完整的反向代理功能) | +| WebSocket 支持 | 手动实现 | 需额外处理 | +| DNS 服务器 | 内置 | 无(需单独实现) | + +## 建议 + +基于以上分析,建议: + +1. **保留 DNS 服务器功能**:当前实现的 DNS 服务器功能是必要的,应保留 +2. **使用 httputil.ReverseProxy 替代手动转发**: + - 替换 `forwardRequest` 函数 + - 保留 WebSocket 手动处理逻辑(或寻找更合适的 WebSocket 代理方案) +3. **保持路由匹配逻辑**:当前的路由匹配逻辑与业务需求相关,应保留 +4. **保持配置管理**:当前的配置管理方式合理,应保留 + +## 迁移方案 + +1. **导入 httputil 包**:在 devproxy.go 中添加 `net/http/httputil` 导入 +2. **创建 ReverseProxy 实例**:为每个路由创建对应的 ReverseProxy 实例 +3. **修改请求处理**:在 `handleHTTPRequest` 中使用 ReverseProxy 处理非 WebSocket 请求 +4. **保留 WebSocket 处理**:继续使用当前的 WebSocket 处理逻辑 +5. **测试验证**:确保所有功能正常工作 + +## 总结 + +使用 httputil.ReverseProxy 可以: +- 减少代码复杂度 +- 提高稳定性和性能 +- 降低维护成本 +- 利用标准库的优化 + +同时,当前实现中的 DNS 服务器、路由匹配和配置管理功能仍然是必要的,应保留。 \ No newline at end of file diff --git a/cmds/devproxycmd/system.go b/cmds/devproxycmd/system.go new file mode 100644 index 000000000..dd1328f91 --- /dev/null +++ b/cmds/devproxycmd/system.go @@ -0,0 +1,84 @@ +package devproxycmd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +// InstallSystemIntegration 安装系统集成 +func InstallSystemIntegration() error { + // 检查是否为macOS + if runtime.GOOS != "darwin" && !strings.Contains(strings.ToLower(os.Getenv("OSTYPE")), "darwin") { + return fmt.Errorf("system integration is only supported on macOS") + } + + // 创建/etc/resolver目录 + resolverDir := "/etc/resolver" + if err := os.MkdirAll(resolverDir, 0o755); err != nil { + if os.IsPermission(err) { + return fmt.Errorf("permission denied: need to run with sudo to modify system files") + } + return fmt.Errorf("failed to create resolver directory: %w", err) + } + + // 创建lava resolver配置 + resolverFile := filepath.Join(resolverDir, "lava") + configContent := fmt.Sprintf(`nameserver 127.0.0.1 +port %d +`, config.DNS.Port) + + if err := os.WriteFile(resolverFile, []byte(configContent), 0o644); err != nil { + if os.IsPermission(err) { + return fmt.Errorf("permission denied: need to run with sudo to modify system files") + } + return fmt.Errorf("failed to write resolver config: %w", err) + } + + fmt.Println("System integration installed successfully") + fmt.Println("Created:", resolverFile) + fmt.Println("This config will route all *.lava queries to our DNS server") + return nil +} + +// UninstallSystemIntegration 卸载系统集成 +func UninstallSystemIntegration() error { + // 检查是否为macOS + if runtime.GOOS != "darwin" && !strings.Contains(strings.ToLower(os.Getenv("OSTYPE")), "darwin") { + return fmt.Errorf("system integration is only supported on macOS") + } + + // 删除lava resolver配置 + resolverFile := "/etc/resolver/lava" + if err := os.Remove(resolverFile); err != nil { + if os.IsNotExist(err) { + fmt.Println("System integration not found") + return nil + } + if os.IsPermission(err) { + return fmt.Errorf("permission denied: need to run with sudo to modify system files") + } + return fmt.Errorf("failed to remove resolver config: %w", err) + } + + fmt.Println("System integration uninstalled successfully") + fmt.Println("Removed:", resolverFile) + return nil +} + +// ShowRoutes 显示当前路由 +func ShowRoutes() error { + fmt.Println("Current routes:") + fmt.Println("Pattern\t\tTarget\t\tPath") + fmt.Println("---------------------------------------------------") + for _, route := range config.Routes { + path := route.Path + if path == "" { + path = "(original)" + } + fmt.Printf("%s\t\t%s\t\t%s\n", route.Pattern, route.Target, path) + } + return nil +} diff --git a/cmds/devproxycmd/websocket_proxy_analysis.md b/cmds/devproxycmd/websocket_proxy_analysis.md new file mode 100644 index 000000000..150c539cf --- /dev/null +++ b/cmds/devproxycmd/websocket_proxy_analysis.md @@ -0,0 +1,40 @@ +# WebSocket 代理分析:httputil.ReverseProxy vs 自定义实现 + +## httputil.ReverseProxy 对 WebSocket 的支持 + +根据搜索结果,从 Go 1.12 开始,`httputil.ReverseProxy` 能够自动转发 WebSocket 请求。这意味着标准库的反向代理已经内置了对 WebSocket 的支持,无需额外配置。 + +## 当前实现分析 + +当前 devproxy 模块的 WebSocket 处理逻辑: + +1. **检测 WebSocket 连接**:在 `handleHTTPRequest` 函数中,通过检查 `Upgrade` 头是否为 "websocket" 来判断是否为 WebSocket 连接 +2. **自定义 WebSocket 处理**:如果是 WebSocket 连接,调用 `forwardWebSocket` 函数处理 +3. **手动实现**:`forwardWebSocket` 函数手动实现了 WebSocket 连接的升级、目标服务器连接和双向数据转发 + +## 兼容性问题 + +虽然 `httputil.ReverseProxy` 支持 WebSocket,但在当前代码中直接使用它处理 WebSocket 连接存在以下问题: + +1. **框架兼容性**:`httputil.ReverseProxy` 是为标准 `net/http` 包设计的,而当前项目使用的是 Fiber 框架(基于 fasthttp) +2. **请求/响应转换**:需要在 fasthttp 请求/响应和标准 http 请求/响应之间进行转换 +3. **WebSocket 升级**:Fiber 的 WebSocket 升级机制与标准 http 包不同 + +## 建议方案 + +基于以上分析,建议采用以下方案: + +1. **保留当前的 WebSocket 处理逻辑**:继续使用 `forwardWebSocket` 函数处理 WebSocket 连接 +2. **使用 httputil.ReverseProxy 处理普通 HTTP 请求**:继续使用修改后的 `forwardRequest` 函数处理普通 HTTP 请求 +3. **优化 WebSocket 处理**:如果需要,可以对 `forwardWebSocket` 函数进行优化,例如添加错误处理、超时设置等 + +## 理由 + +1. **兼容性**:当前的 WebSocket 处理逻辑与 Fiber 框架完全兼容 +2. **可靠性**:当前的 WebSocket 处理逻辑已经实现并且工作正常 +3. **性能**:手动实现的 WebSocket 处理逻辑可能比通过转换层使用 `httputil.ReverseProxy` 更高效 +4. **维护性**:保留当前的 WebSocket 处理逻辑可以保持代码的清晰性和可维护性 + +## 总结 + +虽然 `httputil.ReverseProxy` 支持 WebSocket,但由于框架兼容性问题,在当前项目中保留自定义的 WebSocket 处理逻辑是更合适的选择。这样可以充分利用 `httputil.ReverseProxy` 处理普通 HTTP 请求的优势,同时确保 WebSocket 连接能够正常工作。 \ No newline at end of file diff --git a/cmds/envcmd/cmd.go b/cmds/envcmd/cmd.go new file mode 100644 index 000000000..9f9f71329 --- /dev/null +++ b/cmds/envcmd/cmd.go @@ -0,0 +1,36 @@ +package envcmd + +import ( + "context" + "fmt" + + "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/pretty" + "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + return &redant.Command{ + Use: "envs", + Short: "show all envs", + Handler: func(ctx context.Context, i *redant.Invocation) error { + defer recovery.Exit() + + env.Reload() + + fmt.Println("config path:", config.GetConfigPath()) + envs := config.LoadEnvMap(config.GetConfigPath()) + for name, cfg := range envs { + envData := env.Get(name) + if envData != "" { + cfg.Default = envData + } + } + + pretty.Println(envs) + return nil + }, + } +} diff --git a/cmds/fileservercmd/cmd.go b/cmds/fileservercmd/cmd.go index a4c20a698..fadf81c6b 100644 --- a/cmds/fileservercmd/cmd.go +++ b/cmds/fileservercmd/cmd.go @@ -9,15 +9,17 @@ import ( "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/redant" "github.com/valyala/fasthttp" + + "github.com/pubgo/lava/v2/core/running" + "github.com/pubgo/lava/v2/pkg/netutil" ) func New() *redant.Command { return &redant.Command{ Use: "fileserver ", - Short: "serve `pwd` via http at *:8080", + Short: "serve `pwd` via http at *:8080 or ", Handler: func(ctx context.Context, command *redant.Invocation) error { defer recovery.Exit() @@ -43,7 +45,9 @@ func New() *redant.Command { Handler: fs.NewRequestHandler(), Logger: log.NewStd(log.GetLogger("fileserver")), } - go func() { assert.Must(s.ListenAndServe(fmt.Sprintf(":%v", port))) }() + go func() { + assert.Exit(netutil.SkipServerClosedError(s.ListenAndServe(fmt.Sprintf(":%v", port)))) + }() <-ctx.Done() return s.ShutdownWithContext(ctx) diff --git a/cmds/grpcservercmd/cmd.go b/cmds/grpcservercmd/cmd.go index 46183f541..65cd05e21 100644 --- a/cmds/grpcservercmd/cmd.go +++ b/cmds/grpcservercmd/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/pubgo/lava/v2/core/lifecycle" "github.com/pubgo/lava/v2/core/supervisor" + supervisordebug "github.com/pubgo/lava/v2/core/supervisor/debug" "github.com/pubgo/lava/v2/pkg/cliutil" "github.com/pubgo/lava/v2/servers/grpcs" ) @@ -26,6 +27,7 @@ func New(di *dix.Dix) *redant.Command { })) manager := supervisor.Default(params.LC) + supervisordebug.Register(manager) for _, svc := range params.Services { assert.Exit(manager.Add(svc)) } diff --git a/cmds/httpservercmd/cmd.go b/cmds/httpservercmd/cmd.go index 4140a588a..70efb3322 100644 --- a/cmds/httpservercmd/cmd.go +++ b/cmds/httpservercmd/cmd.go @@ -8,8 +8,8 @@ import ( "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/redant" - "github.com/pubgo/lava/v2/core/lifecycle" "github.com/pubgo/lava/v2/core/supervisor" + supervisordebug "github.com/pubgo/lava/v2/core/supervisor/debug" "github.com/pubgo/lava/v2/pkg/cliutil" "github.com/pubgo/lava/v2/servers/https" ) @@ -20,17 +20,18 @@ func New(di *dix.Dix) *redant.Command { Short: cliutil.UsageDesc("%s http service", version.Project()), Handler: func(ctx context.Context, i *redant.Invocation) error { di.Provide(https.New) + di.Provide(supervisor.Default) params := dix.Inject(di, new(struct { - LC lifecycle.Getter - Services []supervisor.Service + Services []supervisor.Service + Supervisor *supervisor.Manager })) - manager := supervisor.Default(params.LC) + supervisordebug.Register(params.Supervisor) for _, svc := range params.Services { - assert.Exit(manager.Add(svc)) + assert.Exit(params.Supervisor.Add(svc)) } - return manager.Run(ctx) + return params.Supervisor.Run(ctx) }, } } diff --git a/cmds/schedulercmd/cmd.go b/cmds/schedulercmd/cmd.go index 844759f76..a43e5a826 100644 --- a/cmds/schedulercmd/cmd.go +++ b/cmds/schedulercmd/cmd.go @@ -2,42 +2,142 @@ package schedulercmd import ( "context" + "os" "github.com/pubgo/dix/v2" "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/log" "github.com/pubgo/redant" "github.com/pubgo/lava/v2/core/lifecycle" - "github.com/pubgo/lava/v2/core/scheduler" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/core/scheduler/schedulerbuilder" - "github.com/pubgo/lava/v2/core/scheduler/schedulerdebug" "github.com/pubgo/lava/v2/core/supervisor" + supervisordebug "github.com/pubgo/lava/v2/core/supervisor/debug" + "github.com/pubgo/lava/v2/core/tunnel" + "github.com/pubgo/lava/v2/core/tunnel/tunnelagent" + "github.com/pubgo/lava/v2/core/tunnel/tunneldebug" "github.com/pubgo/lava/v2/pkg/cliutil" "github.com/pubgo/lava/v2/servers/https" ) func New(di *dix.Dix) *redant.Command { return &redant.Command{ - Use: "scheduler", + Use: "cron", Short: cliutil.UsageDesc("crontab scheduler service %s(%s)", version.Project(), version.Version()), Handler: func(ctx context.Context, i *redant.Invocation) error { di.Provide(schedulerbuilder.NewService) di.Provide(https.New) params := dix.Inject(di, new(struct { LC lifecycle.Getter - Services []supervisor.Service - Manager scheduler.JobManager + Services []supervisor.Service `dix:"scheduler"` })) - schedulerdebug.Init(params.Manager) - manager := supervisor.Default(params.LC) + supervisordebug.Register(manager) for _, svc := range params.Services { assert.Exit(manager.Add(svc)) } + // 集成 Tunnel Agent + // Agent 主动连接 Gateway,将本服务的 HTTP 和 Debug 端点暴露出去 + gatewayAddr := os.Getenv("TUNNEL_GATEWAY_ADDR") + if gatewayAddr == "" { + gatewayAddr = "localhost:7007" // 默认 Gateway 地址 + } + + // 获取本地服务地址(通过环境变量配置) + httpAddr := os.Getenv("HTTP_ADDR") + if httpAddr == "" { + httpAddr = ":" + running.HttpPort.String() + } + debugAddr := os.Getenv("DEBUG_ADDR") + if debugAddr == "" { + debugAddr = ":" + running.HttpPort.String() + } + + // 获取服务名,优先使用环境变量,其次使用 buildinfo,最后使用默认值 + serviceName := os.Getenv("SERVICE_NAME") + if serviceName == "" { + serviceName = version.Project() + } + if serviceName == "" { + serviceName = "scheduler" + } + + serviceVersion := version.Version() + if serviceVersion == "" { + serviceVersion = "dev" + } + + agent := tunnelagent.NewAgent(&tunnel.AgentConfig{ + GatewayAddr: gatewayAddr, + ServiceName: serviceName, + ServiceVersion: serviceVersion, + Metadata: map[string]string{ + "instance": os.Getenv("HOSTNAME"), + }, + + Endpoints: []tunnel.EndpointConfig{ + {Type: "http", LocalAddr: httpAddr, Path: "/"}, + {Type: "debug", LocalAddr: debugAddr, Path: "/debug"}, + }, + }) + + err := agent.Start(ctx) + if err != nil { + log.Error().Err(err).Msg("Failed to start tunnel agent") + } else { + // 注册到 tunneldebug,可以在 /debug/tunnel 查看 Agent 状态 + tunneldebug.SetAgent(agent) + assert.Exit(manager.Add(&tunnelAgentService{agent: agent})) + log.Info(). + Str("gateway", gatewayAddr). + Str("service", version.Project()). + Msg("Tunnel Agent integrated") + } + return manager.Run(ctx) }, } } + +// tunnelAgentService 包装 Agent 为 supervisor.Service +type tunnelAgentService struct { + agent tunnel.Agent + err error +} + +func (s *tunnelAgentService) Name() string { + return "tunnel-agent" +} + +func (s *tunnelAgentService) Error() error { + return s.err +} + +func (s *tunnelAgentService) String() string { + return "Tunnel Agent Service - connects to gateway and exposes local services" +} + +func (s *tunnelAgentService) Serve(ctx context.Context) error { + log.Info().Msg("Starting Tunnel Agent...") + if err := s.agent.Start(ctx); err != nil { + s.err = err + return err + } + + // 等待上下文取消 + <-ctx.Done() + + log.Info().Msg("Stopping Tunnel Agent...") + return s.agent.Stop(context.Background()) +} + +func (s *tunnelAgentService) Metric() *supervisor.Metric { + return &supervisor.Metric{ + Name: s.Name(), + Status: supervisor.StatusRunning, + } +} diff --git a/cmds/tunnelcmd/cmd.go b/cmds/tunnelcmd/cmd.go new file mode 100644 index 000000000..6de90cb40 --- /dev/null +++ b/cmds/tunnelcmd/cmd.go @@ -0,0 +1,242 @@ +package tunnelcmd + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/gofiber/fiber/v3" + "github.com/pubgo/dix/v2" + "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/redant" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/lifecycle" + "github.com/pubgo/lava/v2/core/supervisor" + supervisordebug "github.com/pubgo/lava/v2/core/supervisor/debug" + "github.com/pubgo/lava/v2/core/tunnel" + "github.com/pubgo/lava/v2/core/tunnel/tunneldebug" + "github.com/pubgo/lava/v2/core/tunnel/tunnelgateway" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // 注册 yamux 传输 + "github.com/pubgo/lava/v2/pkg/cliutil" +) + +// TunnelConfig Tunnel Gateway 配置 +type TunnelConfig struct { + // ListenAddr Agent 连接监听地址 + ListenAddr string `yaml:"listen_addr" default:":7007"` + // HTTPPort HTTP 代理端口 + HTTPPort int `yaml:"http_port" default:"8888"` + // GRPCPort gRPC 代理端口 + GRPCPort int `yaml:"grpc_port" default:"9999"` + // DebugPort Debug 代理端口 + DebugPort int `yaml:"debug_port" default:"6066"` +} + +// Config 配置结构 +type Config struct { + Tunnel *TunnelConfig `yaml:"tunnel"` +} + +// tunnelGatewayService 包装 Gateway 为 supervisor.Service +type tunnelGatewayService struct { + gateway tunnel.Gateway + err error +} + +func (s *tunnelGatewayService) Name() string { + return "tunnel-gateway" +} + +func (s *tunnelGatewayService) Error() error { + return s.err +} + +func (s *tunnelGatewayService) String() string { + return "Tunnel Gateway Service - accepts agent connections and proxies requests" +} + +func (s *tunnelGatewayService) Serve(ctx context.Context) error { + if err := s.gateway.Start(ctx); err != nil { + s.err = err + return err + } + + // 等待上下文取消 + <-ctx.Done() + + return s.gateway.Stop(context.Background()) +} + +func (s *tunnelGatewayService) Metric() *supervisor.Metric { + return &supervisor.Metric{ + Name: s.Name(), + Status: supervisor.StatusRunning, + } +} + +// debugServerService 内嵌的 debug 服务器 +type debugServerService struct { + app *fiber.App + addr string + err error +} + +func (s *debugServerService) Name() string { return "debug-server" } +func (s *debugServerService) Error() error { return s.err } +func (s *debugServerService) String() string { + return "Debug Server - provides management UI at " + s.addr +} + +func (s *debugServerService) Serve(ctx context.Context) error { + go func() { + <-ctx.Done() + _ = s.app.Shutdown() + }() + + log.Info().Str("addr", s.addr).Msg("Debug server started") + if err := s.app.Listen(s.addr); err != nil && err != http.ErrServerClosed { + s.err = err + return err + } + return nil +} + +func (s *debugServerService) Metric() *supervisor.Metric { + return &supervisor.Metric{ + Name: s.Name(), + Status: supervisor.StatusRunning, + } +} + +// newDebugServer 创建 debug 服务器 +func newDebugServer(addr string) *debugServerService { + app := fiber.New() + + // 挂载 debug 路由 + app.Use("/debug", debug.App()) + + // 根路由重定向到 tunnel dashboard + app.Get("/", func(c fiber.Ctx) error { + return c.Redirect().To("/debug/tunnel") + }) + + return &debugServerService{ + app: app, + addr: addr, + } +} + +// New 创建 tunnel gateway 命令 +func New(di *dix.Dix) *redant.Command { + return &redant.Command{ + Use: "tunnel", + Short: cliutil.UsageDesc("tunnel service %s(%s)", version.Project(), version.Version()), + Handler: func(ctx context.Context, i *redant.Invocation) error { + fmt.Println("Usage: lava tunnel [command] [arguments]") + fmt.Println("Available commands:") + fmt.Println(" gateway Run tunnel gateway") + return nil + }, + Children: []*redant.Command{ + newGatewayCommand(di), + }, + } +} + +func newGatewayCommand(di *dix.Dix) *redant.Command { + return &redant.Command{ + Use: "gateway", + Short: cliutil.UsageDesc("tunnel gateway service %s(%s)", version.Project(), version.Version()), + Handler: func(ctx context.Context, i *redant.Invocation) error { + // 设置默认值(直接从环境变量读取,不依赖配置文件) + tunnelCfg := &TunnelConfig{ + ListenAddr: ":7007", + HTTPPort: 8888, + GRPCPort: 9999, + DebugPort: 6066, + } + + // 环境变量覆盖 + if addr := os.Getenv("TUNNEL_LISTEN_ADDR"); addr != "" { + tunnelCfg.ListenAddr = addr + } + if port := os.Getenv("TUNNEL_HTTP_PORT"); port != "" { + if p, err := parsePort(port); err == nil { + tunnelCfg.HTTPPort = p + } + } + if port := os.Getenv("TUNNEL_GRPC_PORT"); port != "" { + if p, err := parsePort(port); err == nil { + tunnelCfg.GRPCPort = p + } + } + if port := os.Getenv("TUNNEL_DEBUG_PORT"); port != "" { + if p, err := parsePort(port); err == nil { + tunnelCfg.DebugPort = p + } + } + + // 创建 Gateway + gateway := tunnelgateway.NewGateway(&tunnel.GatewayConfig{ + ListenAddr: tunnelCfg.ListenAddr, + HTTPPort: tunnelCfg.HTTPPort, + GRPCPort: tunnelCfg.GRPCPort, + DebugPort: tunnelCfg.DebugPort, + }) + + err := gateway.Start(ctx) + if err != nil { + log.Error().Err(err).Msg("Gateway start failed") + return err + } + + // 注册到 debug 界面 + tunneldebug.SetGateway(gateway) + + params := dix.Inject(di, new(struct { + LC lifecycle.Getter + Services []supervisor.Service + })) + + manager := supervisor.Default(params.LC) + supervisordebug.Register(manager) + for _, svc := range params.Services { + assert.Exit(manager.Add(svc)) + } + + // 添加 Gateway 服务 + if err := manager.Add(&tunnelGatewayService{gateway: gateway}); err != nil { + return err + } + + // 添加 Debug 服务器(管理界面) + debugAddr := ":6067" // 使用独立端口,避免与 debug proxy 冲突 + if addr := os.Getenv("TUNNEL_ADMIN_ADDR"); addr != "" { + debugAddr = addr + } + if err := manager.Add(newDebugServer(debugAddr)); err != nil { + return err + } + + log.Info(). + Str("listen_addr", tunnelCfg.ListenAddr). + Int("http_port", tunnelCfg.HTTPPort). + Int("grpc_port", tunnelCfg.GRPCPort). + Int("debug_port", tunnelCfg.DebugPort). + Str("admin_addr", debugAddr). + Msg("Starting Tunnel Gateway") + + return manager.Run(ctx) + }, + } +} + +func parsePort(s string) (int, error) { + var port int + _, err := fmt.Sscanf(s, "%d", &port) + return port, err +} diff --git a/cmds/versioncmd/cmd.go b/cmds/versioncmd/cmd.go index 80aebf4a6..f0ae82166 100644 --- a/cmds/versioncmd/cmd.go +++ b/cmds/versioncmd/cmd.go @@ -6,9 +6,9 @@ import ( "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/redant" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/pkg/cliutil" ) @@ -19,11 +19,14 @@ func New() *redant.Command { Short: cliutil.UsageDesc("%s version info", version.Project()), Handler: func(ctx context.Context, i *redant.Invocation) error { defer recovery.Exit() + running.CheckVersion() + fmt.Println("project:", version.Project()) fmt.Println("version:", version.Version()) fmt.Println("commit-id:", version.CommitID()) fmt.Println("build-time:", version.BuildTime()) fmt.Println("instance-id:", running.InstanceID) + fmt.Println("system-info:", running.GetSysInfo()) return nil }, } diff --git a/cmds/watchcmd/cmd.go b/cmds/watchcmd/cmd.go new file mode 100644 index 000000000..e26881a13 --- /dev/null +++ b/cmds/watchcmd/cmd.go @@ -0,0 +1,364 @@ +package watchcmd + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/pubgo/redant" + "gopkg.in/yaml.v3" + + "github.com/pubgo/lava/v2/pkg/cliutil" +) + +type WatcherConfig struct { + Name string `yaml:"name"` + Directory string `yaml:"directory"` + Patterns []string `yaml:"patterns"` + Commands []string `yaml:"commands"` + Ignore []string `yaml:"ignore"` + IgnorePatterns []string `yaml:"ignore_patterns"` + RunOnStartup bool `yaml:"run_on_startup"` + Timeout int `yaml:"timeout"` +} + +type WatchConfig struct { + Watchers []WatcherConfig `yaml:"watchers"` +} + +type Config struct { + Watch WatchConfig `yaml:"watch"` +} + +func New() *redant.Command { + return &redant.Command{ + Use: "watch", + Aliases: []string{"w"}, + Short: cliutil.UsageDesc("Watch files for changes and run commands"), + Long: "Watch files for changes and run commands automatically", + Handler: func(ctx context.Context, i *redant.Invocation) error { + // 加载配置文件 + cfg, err := loadConfig() + if err != nil { + log.Printf("Warning: failed to load config: %v, using default config", err) + // 使用默认配置 + cfg = &Config{ + Watch: WatchConfig{ + Watchers: []WatcherConfig{ + { + Name: "default", + Directory: ".", + Patterns: []string{ + "*.proto", + "*.go", + "!**/dist", + "!**/build", + "!**/vendor", + "!**/node_modules", + "!**/.git", + "!*.tmp", + "!*~", + "!.DS_Store", + }, + Commands: []string{ + "protobuild gen", + "go build ./...", + }, + RunOnStartup: false, + Timeout: 30, + }, + }, + }, + } + } + + // 如果没有配置 watcher,使用默认配置 + if len(cfg.Watch.Watchers) == 0 { + cfg.Watch.Watchers = []WatcherConfig{ + { + Name: "default", + Directory: ".", + Patterns: []string{ + "*.proto", + "*.go", + "!**/dist", + "!**/build", + "!**/vendor", + "!**/node_modules", + "!**/.git", + "!*.tmp", + "!*~", + "!.DS_Store", + }, + Commands: []string{ + "protobuild gen", + "go build ./...", + }, + RunOnStartup: false, + Timeout: 30, + }, + } + } + + // 运行所有 watcher + return runWatchers(cfg.Watch.Watchers) + }, + } +} + +func loadConfig() (*Config, error) { + // 按优先级查找配置文件 + configPaths := []string{ + ".lava/lava.yaml", + ".lava.yaml", + "lava.yaml", + } + + var configPath string + for _, path := range configPaths { + if _, err := os.Stat(path); err == nil { + configPath = path + break + } + } + + if configPath == "" { + return nil, fmt.Errorf("no config file found") + } + + // 读取配置文件 + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) + } + + // 解析 YAML + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) + } + + return &cfg, nil +} + +func runWatchers(watchers []WatcherConfig) error { + var wg sync.WaitGroup + errChan := make(chan error, len(watchers)) + + // 为每个 watcher 启动一个 goroutine + for _, watcherCfg := range watchers { + wg.Add(1) + go func(cfg WatcherConfig) { + defer wg.Done() + if err := runWatcher(cfg); err != nil { + errChan <- fmt.Errorf("watcher %s: %w", cfg.Name, err) + } + }(watcherCfg) + } + + // 等待所有 watcher 完成 + go func() { + wg.Wait() + close(errChan) + }() + + // 收集错误 + var errors []error + for err := range errChan { + if err != nil { + errors = append(errors, err) + } + } + + if len(errors) > 0 { + return fmt.Errorf("watcher errors: %v", errors) + } + + return nil +} + +func runWatcher(cfg WatcherConfig) error { + // 合并 pattern + // 1. patterns + // 2. !ignore (转换为排除模式) + // 3. !ignore_patterns (转换为排除模式) + finalPatterns := make([]string, 0, len(cfg.Patterns)+len(cfg.Ignore)+len(cfg.IgnorePatterns)) + finalPatterns = append(finalPatterns, cfg.Patterns...) + + // 处理 legacy ignore 配置 + for _, ign := range cfg.Ignore { + finalPatterns = append(finalPatterns, "!"+ign) + } + for _, ign := range cfg.IgnorePatterns { + finalPatterns = append(finalPatterns, "!"+ign) + } + + // 提取包含和排除列表以便后续使用 + _, excludes := SplitPatterns(finalPatterns) + + // 创建文件系统监控器 + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create watcher: %w", err) + } + defer func() { + if err := watcher.Close(); err != nil { + log.Printf("failed to close watcher: %v", err) + } + }() + + // 添加监控目录 + err = addWatchDir(watcher, cfg.Directory, excludes, true) + if err != nil { + return fmt.Errorf("failed to add watch directory %s: %w", cfg.Directory, err) + } + + // 如果配置了启动时执行命令 + if cfg.RunOnStartup { + log.Printf("[%s] Running commands on startup...", cfg.Name) + for _, cmdStr := range cfg.Commands { + runCommand(cfg.Name, cmdStr, cfg.Timeout) + } + } + + // 监控文件变更 + log.Printf("[%s] Watching directory: %s", cfg.Name, cfg.Directory) + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + // 检查文件是否匹配 + // 必须匹配某个 include 模式,且不匹配任何 exclude 模式 + if !Match(event.Name, finalPatterns) { + continue + } + + // 处理文件变更 + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 { + log.Printf("[%s] File changed: %s", cfg.Name, event.Name) + + // 运行配置的命令 + for _, cmdStr := range cfg.Commands { + runCommand(cfg.Name, cmdStr, cfg.Timeout) + } + } + + // 处理目录创建,添加新目录到监控 + if event.Op&fsnotify.Create != 0 { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + log.Printf("[%s] New directory created: %s, adding to watch list", cfg.Name, event.Name) + if err := addWatchDir(watcher, event.Name, excludes, true); err != nil { + log.Printf("[%s] failed to add watch directory %s: %v", cfg.Name, event.Name, err) + } + } + } + + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + log.Printf("[%s] Watcher error: %v", cfg.Name, err) + } + } +} + +func addWatchDir(watcher *fsnotify.Watcher, dir string, excludes []string, verbose bool) error { + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // 忽略某些目录 + if info.IsDir() { + // 如果是根目录,不忽略 + if path == dir { + // 添加目录到监控 + if err := watcher.Add(path); err != nil { + return err + } + if verbose { + log.Printf("Watching directory: %s", path) + } + return nil + } + + // 检查是否应该排除该目录 + if MatchAny(path, excludes) { + return filepath.SkipDir + } + + // 添加目录到监控 + err := watcher.Add(path) + if err != nil { + return err + } + + if verbose { + log.Printf("Watching directory: %s", path) + } + } + + return nil + }) +} + +func runCommand(watcherName, cmdStr string, timeout int) { + log.Printf("[%s] Running command: %s", watcherName, cmdStr) + + // 解析命令 + var cmd *exec.Cmd + if strings.Contains(cmdStr, " ") { + parts := strings.Split(cmdStr, " ") + cmd = exec.Command(parts[0], parts[1:]...) + } else { + cmd = exec.Command(cmdStr) + } + + // 设置命令环境 + cmd.Env = os.Environ() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // 运行命令 + err := cmd.Start() + if err != nil { + log.Printf("[%s] Command failed to start: %v", watcherName, err) + return + } + + // 等待命令完成或超时 + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case err := <-done: + if err != nil { + log.Printf("[%s] Command failed: %v", watcherName, err) + } else { + log.Printf("[%s] Command completed successfully", watcherName) + } + case <-ctx.Done(): + log.Printf("[%s] Command timeout, killing process", watcherName) + if err := cmd.Process.Kill(); err != nil { + log.Printf("[%s] Failed to kill process: %v", watcherName, err) + } + <-done + log.Printf("[%s] Command killed", watcherName) + } +} diff --git a/cmds/watchcmd/match.go b/cmds/watchcmd/match.go new file mode 100644 index 000000000..2e8cd9d99 --- /dev/null +++ b/cmds/watchcmd/match.go @@ -0,0 +1,63 @@ +package watchcmd + +import ( + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" +) + +// Match checks if the path matches the patterns logic. +// Patterns can include inclusion patterns and exclusion patterns (starting with !). +// Exclusions take precedence. +// If no inclusion patterns are present, it implies matching everything (unless excluded). +func Match(path string, patterns []string) bool { + includes, excludes := SplitPatterns(patterns) + + // 1. Check excludes first + if MatchAny(path, excludes) { + return false + } + + // 2. Check includes + // If no include patterns are provided, we assume everything matches (subject to exclusions) + if len(includes) == 0 { + return true + } + + return MatchAny(path, includes) +} + +// SplitPatterns splits patterns into includes and excludes. +// Patterns starting with '!' are considered excludes (with '!' stripped). +func SplitPatterns(patterns []string) (includes, excludes []string) { + for _, p := range patterns { + if strings.HasPrefix(p, "!") { + excludes = append(excludes, strings.TrimPrefix(p, "!")) + } else { + includes = append(includes, p) + } + } + return includes, excludes +} + +// MatchAny checks if path matches any of the provided patterns. +// It supports doublestar syntax. +// If a pattern contains path separators, it matches against the full path. +// Otherwise, it matches against the base filename. +func MatchAny(path string, patterns []string) bool { + for _, pattern := range patterns { + // If pattern contains path separator, match against full path + if strings.ContainsAny(pattern, "/\\") { + if matched, _ := doublestar.Match(pattern, path); matched { + return true + } + } else { + // Otherwise match against filename only + if matched, _ := doublestar.Match(pattern, filepath.Base(path)); matched { + return true + } + } + } + return false +} diff --git a/cmds/watchcmd/match_test.go b/cmds/watchcmd/match_test.go new file mode 100644 index 000000000..1be424e20 --- /dev/null +++ b/cmds/watchcmd/match_test.go @@ -0,0 +1,161 @@ +package watchcmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMatch(t *testing.T) { + tests := []struct { + name string + path string + patterns []string + want bool + }{ + { + name: "simple include match", + path: "main.go", + patterns: []string{"*.go"}, + want: true, + }, + { + name: "simple include mismatch", + path: "main.txt", + patterns: []string{"*.go"}, + want: false, + }, + { + name: "path include match", + path: "src/main.go", + patterns: []string{"src/*.go"}, + want: true, + }, + { + name: "doublestar include match", + path: "src/pkg/utils.go", + patterns: []string{"**/*.go"}, + want: true, + }, + { + name: "exclude match", + path: "vendor/lib.go", + patterns: []string{"**/*.go", "!vendor/**"}, + want: false, + }, + { + name: "exclude priority", + path: "main.go", + patterns: []string{"*.go", "!main.go"}, + want: false, + }, + { + name: "filename convenience match", + path: "path/to/file.txt", + patterns: []string{"*.txt"}, + want: true, + }, + { + name: "no includes means match all", + path: "anything.txt", + patterns: []string{"!*.go"}, + want: true, + }, + { + name: "exclude specific file in subdirectory", + path: "src/ignore_me.go", + patterns: []string{"src/*.go", "!src/ignore_me.go"}, + want: false, + }, + { + name: "multiple include match one", + path: "main.py", + patterns: []string{"*.go", "*.py"}, + want: true, + }, + { + name: "multiple include match none", + path: "main.js", + patterns: []string{"*.go", "*.py"}, + want: false, + }, + { + name: "complex doublestar match", + path: "a/b/c/d/e/f.go", + patterns: []string{"a/**/e/*.go"}, + want: true, + }, + { + name: "complex doublestar mismatch", + path: "a/b/c/d/x/f.go", + patterns: []string{"a/**/e/*.go"}, + want: false, + }, + { + name: "dotfile match", + path: ".gitignore", + patterns: []string{".*"}, // .* matches dotfiles in base + want: true, + }, + { + name: "empty patterns match everything", + path: "whatever.txt", + patterns: []string{}, + want: true, + }, + { + name: "exact path match", + path: "cmd/main.go", + patterns: []string{"cmd/main.go"}, + want: true, + }, + { + name: "nested exclude", + path: "src/vendor/pkg/file.go", + patterns: []string{"src/**", "!**/vendor/**"}, + want: false, + }, + { + name: "windows style separator pattern", + path: "foo/bar.txt", + patterns: []string{"foo\\bar.txt"}, // On unix this might be treated literally but code checks ContainsAny "/\\" + // doublestar handle separator based on OS, but logic: + // strings.ContainsAny(pattern, "/\\") -> true + // doublestar.Match("foo\bar.txt", "foo/bar.txt") -> might fail on unix if it expects unix separator + // Let's test a case that works with forward slash which is universal enough + want: false, // Wait, if I use backslash on standard linux/mac it might match if it's treated as char, but code treats it as path separator trigger + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Match(tt.path, tt.patterns) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSplitPatterns(t *testing.T) { + patterns := []string{"*.go", "!vendor", "src/**"} + includes, excludes := SplitPatterns(patterns) + + assert.ElementsMatch(t, []string{"*.go", "src/**"}, includes) + assert.ElementsMatch(t, []string{"vendor"}, excludes) +} + +func TestMatchAny(t *testing.T) { + assert.True(t, MatchAny("main.go", []string{"*.go"})) + assert.True(t, MatchAny("path/to/main.go", []string{"*.go"})) + assert.True(t, MatchAny("path/to/main.go", []string{"path/**/*.go"})) + assert.False(t, MatchAny("main.txt", []string{"*.go"})) +} + +func TestSplitPatternsEmpty(t *testing.T) { + includes, excludes := SplitPatterns([]string{}) + assert.Empty(t, includes) + assert.Empty(t, excludes) +} + +func TestMatchAnyEmpty(t *testing.T) { + assert.False(t, MatchAny("anything", []string{})) +} diff --git a/core/debug/configview/configview.go b/core/debug/configview/configview.go new file mode 100644 index 000000000..a512fbab3 --- /dev/null +++ b/core/debug/configview/configview.go @@ -0,0 +1,387 @@ +package configview + +import ( + "fmt" + "html/template" + "os" + "regexp" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/pubgo/funk/v2/config" + "gopkg.in/yaml.v3" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var sensitivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)password`), + regexp.MustCompile(`(?i)secret`), + regexp.MustCompile(`(?i)token`), + regexp.MustCompile(`(?i)api[_-]?key`), + regexp.MustCompile(`(?i)access[_-]?key`), + regexp.MustCompile(`(?i)private[_-]?key`), + regexp.MustCompile(`(?i)credential`), + regexp.MustCompile(`(?i)auth`), + regexp.MustCompile(`(?i)dsn`), + regexp.MustCompile(`(?i)connection[_-]?string`), +} + +func init() { + debug.Get("/config", func(ctx fiber.Ctx) error { + configPath := config.GetConfigPath() + + // JSON 响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + if configPath == "" { + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "config path not found", + }) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to read config: " + err.Error(), + }) + } + + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to parse config: " + err.Error(), + }) + } + + maskSensitiveData(cfg, "") + + return ctx.JSON(fiber.Map{ + "config_path": configPath, + "config": cfg, + }) + } + + // HTML 页面 + var cfg map[string]any + var configError string + var configYaml string + + if configPath == "" { + configError = "配置文件路径未设置" + } else { + data, err := os.ReadFile(configPath) + if err != nil { + configError = "读取配置文件失败: " + err.Error() + } else { + configYaml = string(data) + if err := yaml.Unmarshal(data, &cfg); err != nil { + configError = "解析配置文件失败: " + err.Error() + } else { + maskSensitiveData(cfg, "") + } + } + } + + // 统计卡片 + keyCount := countKeys(cfg) + statsContent := ui.StatsCard("配置路径", shortenPath(configPath, 30), "") + statsContent += ui.StatsCard("配置项数", fmt.Sprintf("%d", keyCount), "") + statsContent += ui.StatsCard("敏感字段", fmt.Sprintf("%d 个模式", len(sensitivePatterns)), "自动脱敏") + + // 错误提示 + errorContent := template.HTML("") + if configError != "" { + errorContent = ui.Alert(configError, "red") + } + + // 配置树形展示 + configContent := template.HTML("") + if cfg != nil { + configContent = buildConfigTree(cfg, "") + } + + // 环境变量统计 + envCount := len(os.Environ()) + + // 快速操作 + actionsContent := template.HTML(fmt.Sprintf(` +
+ 配置路径 + 环境变量 (%d) + + +
+`, envCount)) + + // YAML 视图 + yamlView := template.HTML(fmt.Sprintf(` +`, + template.HTMLEscapeString(maskYaml(configYaml)))) + + content := template.HTML(fmt.Sprintf(` +
%s
+%s +%s +
+
+

配置内容

+
+
+
%s
+ %s +
+
`, + statsContent, + errorContent, + ui.Card("快速操作", actionsContent), + configContent, + yamlView)) + + html, err := ui.Render(ui.PageData{ + Title: "配置查看", + Description: "应用配置文件查看(敏感信息已脱敏)", + Breadcrumb: []string{"Config"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Get("/config/path", func(ctx fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "config_path": config.GetConfigPath(), + }) + }) + + debug.Get("/config/raw", func(ctx fiber.Ctx) error { + if ctx.Query("confirm") != "yes" { + return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "raw config access requires confirm=yes parameter", + "warning": "raw config may contain sensitive data", + }) + } + + configPath := config.GetConfigPath() + if configPath == "" { + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "config path not found", + }) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to read config: " + err.Error(), + }) + } + + ctx.Set("Content-Type", "text/yaml; charset=utf-8") + return ctx.Send(data) + }) + + debug.Get("/config/env", func(ctx fiber.Ctx) error { + envs := make(map[string]string) + for _, env := range os.Environ() { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + key := parts[0] + value := parts[1] + if isSensitiveKey(key) { + value = maskString(value) + } + envs[key] = value + } + } + + return ctx.JSON(fiber.Map{ + "count": len(envs), + "envs": envs, + }) + }) +} + +func maskSensitiveData(data map[string]any, parentKey string) { + for key, value := range data { + fullKey := key + if parentKey != "" { + fullKey = parentKey + "." + key + } + + switch v := value.(type) { + case map[string]any: + maskSensitiveData(v, fullKey) + case string: + if isSensitiveKey(key) || isSensitiveKey(fullKey) { + data[key] = maskString(v) + } + case []any: + for i, item := range v { + if m, ok := item.(map[string]any); ok { + maskSensitiveData(m, fullKey) + v[i] = m + } + } + } + } +} + +func isSensitiveKey(key string) bool { + for _, pattern := range sensitivePatterns { + if pattern.MatchString(key) { + return true + } + } + return false +} + +func maskString(s string) string { + if len(s) == 0 { + return "" + } + if len(s) <= 4 { + return "****" + } + if len(s) <= 8 { + return s[:1] + "****" + s[len(s)-1:] + } + return s[:2] + "****" + s[len(s)-2:] +} + +func countKeys(m map[string]any) int { + count := 0 + for _, v := range m { + count++ + if sub, ok := v.(map[string]any); ok { + count += countKeys(sub) + } + } + return count +} + +func shortenPath(path string, maxLen int) string { + if len(path) <= maxLen { + return path + } + return "..." + path[len(path)-maxLen+3:] +} + +func buildConfigTree(data map[string]any, indent string) template.HTML { + result := "" + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + + for _, key := range keys { + value := data[key] + switch v := value.(type) { + case map[string]any: + if len(v) == 0 { + result += fmt.Sprintf(`
%s: {}
`, indent, key) + } else { + result += fmt.Sprintf(` +
+ + + + + %s + (%d) + +
%s
+
`, indent, key, len(v), buildConfigTree(v, "")) + } + case []any: + if len(v) == 0 { + result += fmt.Sprintf(`
%s: []
`, indent, key) + } else { + result += fmt.Sprintf(` +
+ + + + + %s + [%d items] + +
%s
+
`, indent, key, len(v), buildArrayTree(v, key)) + } + default: + valueStr := fmt.Sprintf("%v", v) + valueClass := "text-green-400" + if isSensitiveKey(key) { + valueClass = "text-yellow-400" + } + result += fmt.Sprintf(`
%s: %s
`, indent, key, valueClass, template.HTMLEscapeString(valueStr)) + } + } + return template.HTML(result) +} + +func buildArrayTree(arr []any, parentKey string) template.HTML { + result := "" + for i, item := range arr { + switch v := item.(type) { + case map[string]any: + if len(v) == 0 { + result += fmt.Sprintf(`
[%d]: {}
`, i) + } else { + result += fmt.Sprintf(` +
+ + + + + [%d] + +
%s
+
`, i, buildConfigTree(v, "")) + } + case []any: + result += fmt.Sprintf(`
[%d]: [%d items]
`, i, len(v)) + default: + valueStr := fmt.Sprintf("%v", v) + valueClass := "text-green-400" + if isSensitiveKey(parentKey) { + valueClass = "text-yellow-400" + valueStr = maskString(valueStr) + } + result += fmt.Sprintf(`
[%d]: %s
`, i, valueClass, template.HTMLEscapeString(valueStr)) + } + } + return template.HTML(result) +} + +func maskYaml(yaml string) string { + lines := strings.Split(yaml, "\n") + for i, line := range lines { + for _, pattern := range sensitivePatterns { + if pattern.MatchString(line) { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + lines[i] = parts[0] + ": ****" + } + } + } + } + return strings.Join(lines, "\n") +} diff --git a/core/debug/debug/debug.go b/core/debug/debug/debug.go index 186bd77d0..5c57829d4 100644 --- a/core/debug/debug/debug.go +++ b/core/debug/debug/debug.go @@ -1,49 +1,242 @@ package debug import ( - "path/filepath" + "fmt" + "html/template" + "runtime" "sort" + "strings" + "time" - "github.com/gofiber/fiber/v2" - g "github.com/maragudk/gomponents" - c "github.com/maragudk/gomponents/components" - h "github.com/maragudk/gomponents/html" + "github.com/gofiber/fiber/v3" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" ) +var debugStartTime = time.Now() + func init() { initDebug() } func initDebug() { - debug.Get("/", func(ctx *fiber.Ctx) error { - pathMap := make(map[string]any) + // 主页 - 仪表盘 + debug.Get("/", func(ctx fiber.Ctx) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + // 统计卡片 + statsHTML := ui.Grid(4, ui.StatsCard("Goroutines", fmt.Sprintf("%d", runtime.NumGoroutine()), "")+ + ui.StatsCard("堆内存", ui.FormatBytes(m.HeapAlloc), fmt.Sprintf("总分配: %s", ui.FormatBytes(m.TotalAlloc)))+ + ui.StatsCard("GC 次数", fmt.Sprintf("%d", m.NumGC), fmt.Sprintf("暂停: %.2fms", float64(m.PauseTotalNs)/1e6))+ + ui.StatsCard("运行时间", formatDuration(time.Since(debugStartTime)), "")) + // 路由列表 + pathMap := make(map[string][]string) stack := ctx.App().Stack() - for m := range stack { - for r := range stack[m] { - route := stack[m][r] - //if strings.Contains(route.Path, "*") || strings.Contains(route.Path, ":") { - // continue - //} - pathMap[route.Path] = nil + for mi := range stack { + for ri := range stack[mi] { + route := stack[mi][ri] + method := route.Method + if method == "HEAD" { + continue + } + pathMap[route.Path] = append(pathMap[route.Path], method) } } - var pathList []string + pathList := make([]string, 0, len(pathMap)) for k := range pathMap { pathList = append(pathList, k) } sort.Strings(pathList) - var nodes []g.Node - nodes = append(nodes, h.H1(g.Text("routes"))) - for i := range pathList { - path := pathList[i] - nodes = append(nodes, h.A(g.Text(path), g.Attr("href", filepath.Join(string(ctx.Request().Header.Peek("Path-Prefix")), path))), h.Br()) + // 分组路由 + groups := make(map[string][]routeInfo) + for _, path := range pathList { + methods := pathMap[path] + group := getGroup(path) + groups[group] = append(groups[group], routeInfo{Path: path, Methods: methods}) + } + + groupNames := make([]string, 0, len(groups)) + for g := range groups { + groupNames = append(groupNames, g) + } + sort.Strings(groupNames) + + // 构建折叠面板的路由列表 + var routeItems string + for _, group := range groupNames { + routes := groups[group] + var rows template.HTML + for _, r := range routes { + methodBadges := "" + for _, m := range r.Methods { + color := methodColor(m) + methodBadges += string(ui.Badge(m, color)) + " " + } + rows += ui.TR(methodBadges, fmt.Sprintf(`%s`, r.Path, r.Path)) + } + tableHTML := ui.Table([]string{"方法", "路径"}) + rows + ui.TableEnd() + + routeItems += fmt.Sprintf(` +
+ +
+ 📁 + %s + %d +
+ + + +
+
%s
+
`, group, len(routes), tableHTML) } + + totalRoutes := len(pathList) + routesHTML := template.HTML(fmt.Sprintf(` +
+
+
+

🛣️ API 路由

+ %d 个路由 + %d 个分组 +
+
+ +
+
+
%s
+
`, totalRoutes, len(groupNames), routeItems)) + + // 快捷操作 + actionsHTML := template.HTML(` +`) + + content := statsHTML + `
` + actionsHTML + routesHTML + + html, _ := ui.Render(ui.PageData{ + Title: "Debug 控制台", + Description: "应用调试与监控中心", + Content: content, + }) ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - return c.HTML5(c.HTML5Props{Title: "/app/routes", Body: nodes}).Render(ctx) + return ctx.SendString(html) }) } + +type routeInfo struct { + Path string + Methods []string +} + +func getGroup(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) >= 2 { + return "/" + parts[0] + "/" + parts[1] + } + if len(parts) >= 1 { + return "/" + parts[0] + } + return "/" +} + +func methodColor(m string) string { + colors := map[string]string{ + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "purple", + } + if c, ok := colors[m]; ok { + return c + } + return "gray" +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + if d < time.Hour { + return fmt.Sprintf("%.0fm", d.Minutes()) + } + if d < 24*time.Hour { + return fmt.Sprintf("%.1fh", d.Hours()) + } + return fmt.Sprintf("%.1fd", d.Hours()/24) +} diff --git a/core/debug/debug/mux.go b/core/debug/debug/mux.go index 6a185feb4..ba02a637e 100644 --- a/core/debug/debug/mux.go +++ b/core/debug/debug/mux.go @@ -1,79 +1,150 @@ package debug import ( + "crypto/subtle" "net/http" "os" "strings" "sync" + "time" - "github.com/gofiber/fiber/v2" - "github.com/pubgo/funk/v2/assert" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2/config" "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/result" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/funk/v2/strutil" - "github.com/samber/lo" "github.com/valyala/fasthttp" "gopkg.in/yaml.v3" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/running" ) var ( - passwd = running.InstanceID - once sync.Once + passwd = running.InstanceID + once sync.Once + configErr error + startTime = time.Now() ) +// 允许访问的本地 IP 列表 +var localHosts = map[string]bool{ + "localhost": true, + "127.0.0.1": true, + "::1": true, +} + +// secureCompare 使用常量时间比较防止时序攻击 +func secureCompare(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// isLocalHost 检查是否为本地访问 +func isLocalHost(host string) bool { + host = strings.Split(host, ":")[0] + return localHosts[host] +} + +// loadConfig 加载配置(只执行一次) +func loadConfig() { + once.Do(func() { + configPath := config.GetConfigPath() + if configPath == "" { + log.Warn().Msg("debug: config path is empty, using default password") + return + } + + configBytes, err := os.ReadFile(configPath) + if err != nil { + configErr = errors.WrapCaller(err) + log.Err(err).Str("path", configPath).Msg("debug: failed to read config file") + return + } + + var cfg debug.Config + if err := yaml.Unmarshal(configBytes, &cfg); err != nil { + configErr = errors.WrapCaller(err) + log.Err(err).Msg("debug: failed to parse config file") + return + } + + if cfg.Debug.Password != "" { + passwd = cfg.Debug.Password + } + }) +} + func init() { - // log.Info().Str("password", passwd).Msg("debug password") - debug.App().Use(func(c *fiber.Ctx) (gErr error) { + debug.App().Use(func(c fiber.Ctx) (gErr error) { defer recovery.Recovery(func(err error) { err = errors.WrapTags(err, errors.Tags{ "headers": c.GetReqHeaders(), "url": c.Request().URI().String(), }) - gErr = c.JSON(err) + gErr = c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "success": false, + }) }) + // 检查是否为 WebSocket 升级请求 + isWebSocket := strings.EqualFold(string(c.Request().Header.Peek("Upgrade")), "websocket") + + // 提取 token token := strutil.FirstFnNotEmpty( func() string { return c.Query("token") }, func() string { return string(c.Request().Header.Peek("token")) }, + func() string { return string(c.Request().Header.Peek("Authorization")) }, func() string { return c.Cookies("token") }, ) - once.Do(func() { - configBytes := assert.Must1(os.ReadFile(config.GetConfigPath())) - var cfg debug.Config - assert.Must(yaml.Unmarshal(configBytes, &cfg)) - passwd = cfg.Debug.Password - }) + // 移除 Bearer 前缀 + token = strings.TrimPrefix(token, "Bearer ") + + // 加载配置 + loadConfig() + + // 非本地访问需要验证 token + if !isLocalHost(c.Hostname()) { + if !secureCompare(token, passwd) { + log.Warn(). + Str("ip", c.IP()). + Str("path", c.Path()). + Msg("debug: unauthorized access attempt") - host := strings.Split(c.Hostname(), ":")[0] - if host != "localhost" && host != "127.0.0.1" { - if token != passwd { - err := errors.New("token 不存在或者密码不对") - if result.ThrowErr(&gErr, lo.T2(c.WriteString(err.Error())).B) { - return gErr - } - - if err := c.SendStatus(http.StatusInternalServerError); err != nil { - return errors.WrapCaller(err) - } - return err + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ + "error": "unauthorized: invalid or missing token", + "success": false, + }) } } - cc := fasthttp.AcquireCookie() - defer fasthttp.ReleaseCookie(cc) + // WebSocket 请求不设置额外的响应头,避免干扰握手 + if !isWebSocket { + // 设置 cookie 以便后续请求 + if token != "" { + cc := fasthttp.AcquireCookie() + defer fasthttp.ReleaseCookie(cc) - cc.SetKey("token") - cc.SetValue(token) - c.Response().Header.SetCookie(cc) + cc.SetKey("token") + cc.SetValue(token) + cc.SetHTTPOnly(true) + cc.SetSameSite(fasthttp.CookieSameSiteStrictMode) + c.Response().Header.SetCookie(cc) + } + + // 添加响应头 + c.Set("X-Debug-Version", "1.0") + c.Set("X-Request-ID", running.InstanceID) + } - log.Info().Str("path", c.Request().URI().String()).Msg("request") + log.Debug(). + Str("method", c.Method()). + Str("path", c.Path()). + Str("ip", c.IP()). + Bool("websocket", isWebSocket). + Msg("debug request") return c.Next() }) diff --git a/core/debug/dixdebug/init.go b/core/debug/dixdebug/init.go index 08198fa8c..2309fa401 100644 --- a/core/debug/dixdebug/init.go +++ b/core/debug/dixdebug/init.go @@ -5,11 +5,8 @@ import ( "github.com/pubgo/dix/v2/dixhttp" "github.com/pubgo/lava/v2/core/debug" - "github.com/pubgo/lava/v2/pkg/httputil" ) func Init(d *dix.Dix) { - ss := dixhttp.NewServer(d) - debug.Get("/dix", debug.WrapFunc(ss.HandleIndex)) - debug.Get("/api/dependencies", httputil.StripPrefix("/debug", debug.WrapFunc(ss.HandleDependencies))) + debug.Group("/dix", dixhttp.NewServerWithOptions(d, dixhttp.WithBasePath("/debug/dix"))) } diff --git a/core/debug/featurehttp/featurehttp.go b/core/debug/featurehttp/featurehttp.go new file mode 100644 index 000000000..1f3167b28 --- /dev/null +++ b/core/debug/featurehttp/featurehttp.go @@ -0,0 +1,14 @@ +// Package featurehttp 提供 feature flags 的 HTTP 调试接口 +package featurehttp + +import ( + "github.com/pubgo/funk/v2/features/featurehttp" + + "github.com/pubgo/lava/v2/core/debug" +) + +func init() { + srv := featurehttp.NewServer("").WithPrefix("/debug/features") + debug.App().All("/features", debug.Wrap(srv.Handler())) + debug.App().All("/features/*", debug.Wrap(srv.Handler())) +} diff --git a/core/debug/goroutine/goroutine.go b/core/debug/goroutine/goroutine.go new file mode 100644 index 000000000..841eeee4c --- /dev/null +++ b/core/debug/goroutine/goroutine.go @@ -0,0 +1,446 @@ +package goroutine + +import ( + "fmt" + "html/template" + "runtime" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/pubgo/funk/v2/log" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var ( + monitoring atomic.Bool + monitorMu sync.Mutex + goroutineStats []goroutineStat + maxStats = 100 +) + +type goroutineStat struct { + Timestamp time.Time `json:"timestamp"` + Count int `json:"count"` +} + +func init() { + // Goroutine 仪表板 HTML 页面 + debug.Get("/goroutine", func(ctx fiber.Ctx) error { + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "count": runtime.NumGoroutine(), + }) + } + + monitorMu.Lock() + stats := make([]goroutineStat, len(goroutineStats)) + copy(stats, goroutineStats) + monitorMu.Unlock() + + isMonitoring := monitoring.Load() + monitorStatus := "停止" + if isMonitoring { + monitorStatus = "运行中" + } + + // 构建统计卡片 + statsContent := ui.StatsCard("Goroutine 数量", fmt.Sprintf("%d", runtime.NumGoroutine()), "当前") + statsContent += ui.StatsCard("监控状态", monitorStatus, fmt.Sprintf("已采集 %d 个样本", len(stats))) + statsContent += ui.StatsCard("最大样本", fmt.Sprintf("%d", maxStats), "保留数量") + statsContent += ui.StatsCard("CPU 核心", fmt.Sprintf("%d", runtime.NumCPU()), "GOMAXPROCS: "+fmt.Sprintf("%d", runtime.GOMAXPROCS(0))) + + // 趋势分析 + trendContent := template.HTML("") + if len(stats) >= 2 { + first := stats[0].Count + last := stats[len(stats)-1].Count + diff := last - first + trend := "稳定" + trendColor := "blue" + if diff > 10 { + trend = "上升 ↑" + trendColor = "yellow" + } else if diff < -10 { + trend = "下降 ↓" + trendColor = "green" + } + isPossibleLeak := diff > 50 + leakWarning := "" + if isPossibleLeak { + leakWarning = fmt.Sprintf(`
⚠️ 可能存在 Goroutine 泄漏!增长了 %d 个
`, diff) + } + trendContent = template.HTML(fmt.Sprintf(` +
+
+
初始数量
+
%d
+
+
+
当前数量
+
%d
+
+
+
变化
+
%+d
+
+
+
趋势
+
%s
+
+
+%s`, first, last, diff, ui.Badge(trend, trendColor), leakWarning)) + } else { + trendContent = ui.Alert("需要更多数据点,请先启动监控", "yellow") + } + + // 图表数据 + chartData := "[]" + if len(stats) > 0 { + points := make([]string, 0, len(stats)) + for _, s := range stats { + points = append(points, fmt.Sprintf(`{x:"%s",y:%d}`, s.Timestamp.Format("15:04:05"), s.Count)) + } + chartData = "[" + concatStrings(points, ",") + "]" + } + + // 操作按钮和图表 + actionsContent := template.HTML(fmt.Sprintf(` +
+ + + 查看堆栈 + Goroutine 分析 + +
+
+ +
+ +`, + map[bool]string{true: "bg-red-600 hover:bg-red-700", false: "bg-green-600 hover:bg-green-700"}[isMonitoring], + map[bool]string{true: "停止监控", false: "开始监控"}[isMonitoring], + isMonitoring, chartData)) + + // 组合内容 + content := template.HTML(fmt.Sprintf(` +
%s
+%s +%s`, + statsContent, + ui.Card("趋势分析", trendContent), + ui.Card("监控图表", actionsContent))) + + html, err := ui.Render(ui.PageData{ + Title: "Goroutine 监控", + Description: "Goroutine 数量监控和泄漏检测", + Breadcrumb: []string{"Goroutine"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Get("/goroutine/count", func(ctx fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "count": runtime.NumGoroutine(), + }) + }) + + debug.Get("/goroutine/stack", func(ctx fiber.Ctx) error { + buf := make([]byte, 1024*1024) + n := runtime.Stack(buf, true) + ctx.Set("Content-Type", "text/plain; charset=utf-8") + return ctx.Send(buf[:n]) + }) + + debug.Get("/goroutine/stats", func(ctx fiber.Ctx) error { + monitorMu.Lock() + stats := make([]goroutineStat, len(goroutineStats)) + copy(stats, goroutineStats) + monitorMu.Unlock() + + return ctx.JSON(fiber.Map{ + "monitoring": monitoring.Load(), + "count": len(stats), + "stats": stats, + }) + }) + + debug.Post("/goroutine/monitor/start", func(ctx fiber.Ctx) error { + if monitoring.Load() { + return ctx.JSON(fiber.Map{ + "success": false, + "message": "monitoring already started", + }) + } + + monitoring.Store(true) + go monitorGoroutines() + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "monitoring started", + }) + }) + + debug.Post("/goroutine/monitor/stop", func(ctx fiber.Ctx) error { + if !monitoring.Load() { + return ctx.JSON(fiber.Map{ + "success": false, + "message": "monitoring not started", + }) + } + + monitoring.Store(false) + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "monitoring stopped", + }) + }) + + debug.Post("/goroutine/monitor/clear", func(ctx fiber.Ctx) error { + monitorMu.Lock() + goroutineStats = nil + monitorMu.Unlock() + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "stats cleared", + }) + }) + + debug.Get("/goroutine/profile", func(ctx fiber.Ctx) error { + var profiles []fiber.Map + profileMap := make(map[string]int) + + buf := make([]byte, 1024*1024) + n := runtime.Stack(buf, true) + stacks := string(buf[:n]) + + lines := splitLines(stacks) + var currentStack string + for _, line := range lines { + if line == "" { + if currentStack != "" { + profileMap[currentStack]++ + currentStack = "" + } + continue + } + if len(line) > 0 && line[0] != '\t' && line[0] != ' ' { + if currentStack != "" { + profileMap[currentStack]++ + } + currentStack = line + } + } + + type kv struct { + Key string + Value int + } + sorted := make([]kv, 0, len(profileMap)) + for k, v := range profileMap { + sorted = append(sorted, kv{k, v}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Value > sorted[j].Value + }) + + profiles = make([]fiber.Map, 0, len(sorted)) + for _, item := range sorted { + profiles = append(profiles, fiber.Map{ + "state": item.Key, + "count": item.Value, + }) + } + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "total": runtime.NumGoroutine(), + "unique_profiles": len(profiles), + "profiles": profiles, + }) + }) + + debug.Get("/goroutine/leak/check", func(ctx fiber.Ctx) error { + monitorMu.Lock() + stats := make([]goroutineStat, len(goroutineStats)) + copy(stats, goroutineStats) + monitorMu.Unlock() + + if len(stats) < 2 { + return ctx.JSON(fiber.Map{ + "status": "insufficient_data", + "message": "need more data points, start monitoring first", + }) + } + + first := stats[0].Count + last := stats[len(stats)-1].Count + diff := last - first + trend := "stable" + if diff > 10 { + trend = "increasing" + } else if diff < -10 { + trend = "decreasing" + } + + return ctx.JSON(fiber.Map{ + "status": "ok", + "trend": trend, + "initial_count": first, + "current_count": last, + "difference": diff, + "sample_count": len(stats), + "possible_leak": trend == "increasing" && diff > 50, + }) + }) +} + +func monitorGoroutines() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for monitoring.Load() { + <-ticker.C + stat := goroutineStat{ + Timestamp: time.Now(), + Count: runtime.NumGoroutine(), + } + + monitorMu.Lock() + goroutineStats = append(goroutineStats, stat) + if len(goroutineStats) > maxStats { + goroutineStats = goroutineStats[len(goroutineStats)-maxStats:] + } + monitorMu.Unlock() + + log.Debug().Int("count", stat.Count).Msg("goroutine count") + + } +} + +func splitLines(s string) []string { + var lines []string + var current string + for _, c := range s { + if c == '\n' { + lines = append(lines, current) + current = "" + } else { + current += fmt.Sprintf("%c", c) + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func concatStrings(strs []string, sep string) string { + if len(strs) == 0 { + return "" + } + result := strs[0] + for i := 1; i < len(strs); i++ { + result += sep + strs[i] + } + return result +} diff --git a/core/debug/healthy/debug.go b/core/debug/healthy/debug.go index 4b3b1ac4a..7eeef938d 100644 --- a/core/debug/healthy/debug.go +++ b/core/debug/healthy/debug.go @@ -1,45 +1,201 @@ package healthy import ( + "fmt" + "html/template" "net/http" "time" jjson "github.com/goccy/go-json" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2/try" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" "github.com/pubgo/lava/v2/core/healthy" ) func init() { - debug.Get("/health", func(ctx *fiber.Ctx) error { + debug.Get("/health", func(ctx fiber.Ctx) error { dt := make(map[string]*health) + allHealthy := true + healthyCount := 0 + unhealthyCount := 0 + for _, name := range healthy.List() { - h := &health{} - h.Err = try.Try(func() error { + h := &health{ + Status: "healthy", + } + h.Error = try.Try(func() error { defer func(s time.Time) { h.Cost = time.Since(s).String() }(time.Now()) return healthy.Get(name)(ctx) }) + + if h.Error != nil { + h.Status = "unhealthy" + h.ErrMsg = h.Error.Error() + h.Error = nil + allHealthy = false + unhealthyCount++ + } else { + healthyCount++ + } dt[name] = h } - bts, err := jjson.Marshal(dt) - if err != nil { - ctx.Status(http.StatusInternalServerError) - _, err = ctx.Write([]byte(err.Error())) + // JSON 响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + bts, err := jjson.Marshal(fiber.Map{ + "status": statusString(allHealthy), + "timestamp": time.Now().Format(time.RFC3339), + "components": dt, + }) + if err != nil { + ctx.Status(http.StatusInternalServerError) + _, err = ctx.Write([]byte(err.Error())) + return err + } + + ctx.Response().Header.Set("content-type", "application/json") + if allHealthy { + ctx.Status(http.StatusOK) + } else { + ctx.Status(http.StatusServiceUnavailable) + } + _, err = ctx.Write(bts) return err } - ctx.Response().Header.Set("content-type", "application/json") - ctx.Status(http.StatusOK) - _, err = ctx.Write(bts) - return err + // HTML 页面 + overallStatus := "healthy" + overallColor := "green" + if !allHealthy { + overallStatus = "unhealthy" + overallColor = "red" + } + + // 统计卡片 + statsContent := ui.StatsCard("总体状态", overallStatus, "") + statsContent += ui.StatsCard("健康组件", fmt.Sprintf("%d", healthyCount), "") + statsContent += ui.StatsCard("异常组件", fmt.Sprintf("%d", unhealthyCount), "") + statsContent += ui.StatsCard("总组件数", fmt.Sprintf("%d", len(dt)), "") + + // 组件列表 + componentsContent := template.HTML("") + for name, h := range dt { + statusColor := "green" + statusIcon := "✓" + if h.Status == "unhealthy" { + statusColor = "red" + statusIcon = "✗" + } + + errorSection := "" + if h.ErrMsg != "" { + errorSection = fmt.Sprintf(`
%s
`, h.ErrMsg) + } + + componentsContent += template.HTML(fmt.Sprintf(` +
+
+ %s + %s %s +
+
耗时: %s
+ %s +
`, name, statusColor, statusColor, statusIcon, h.Status, h.Cost, errorSection)) + } + + if len(dt) == 0 { + componentsContent = ui.Alert("暂无健康检查组件注册", "yellow") + } + + // 快速操作 + actionsContent := template.HTML(` +`) + + // 总体状态显示 + statusBanner := template.HTML(fmt.Sprintf(` +
+
+
+ %s +
+
系统%s
+
最后检查: %s
+
+
+
+
`, + overallColor, overallColor, + map[bool]string{true: "✅", false: "❌"}[allHealthy], + overallColor, + map[bool]string{true: "健康", false: "异常"}[allHealthy], + time.Now().Format("15:04:05"))) + + content := template.HTML(fmt.Sprintf(` +%s +
%s
+%s +
+
+

组件状态

+
+
%s
+
`, + statusBanner, + statsContent, + ui.Card("快速操作", actionsContent), + componentsContent)) + + html, err := ui.Render(ui.PageData{ + Title: "健康检查", + Description: "系统健康状态监控", + Breadcrumb: []string{"Health"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + // 简单的存活检查 + debug.Get("/healthz", func(ctx fiber.Ctx) error { + return ctx.SendString("ok") }) + + // 就绪检查 + debug.Get("/readyz", func(ctx fiber.Ctx) error { + for _, name := range healthy.List() { + if err := healthy.Get(name)(ctx); err != nil { + ctx.Status(http.StatusServiceUnavailable) + return ctx.JSON(fiber.Map{ + "status": "not ready", + "component": name, + "error": err.Error(), + }) + } + } + return ctx.SendString("ok") + }) +} + +func statusString(healthy bool) string { + if healthy { + return "healthy" + } + return "unhealthy" } type health struct { - Cost string `json:"cost,omitempty"` - Err error `json:"err,omitempty"` - Msg string `json:"err_msg,omitempty"` + Status string `json:"status"` + Cost string `json:"cost,omitempty"` + Error error `json:"-"` + ErrMsg string `json:"error,omitempty"` } diff --git a/core/debug/loglevel/loglevel.go b/core/debug/loglevel/loglevel.go new file mode 100644 index 000000000..994898aad --- /dev/null +++ b/core/debug/loglevel/loglevel.go @@ -0,0 +1,219 @@ +package loglevel + +import ( + "fmt" + "html/template" + "strings" + "sync/atomic" + + "github.com/gofiber/fiber/v3" + "github.com/pubgo/funk/v2/log" + "github.com/rs/zerolog" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var currentLevel atomic.Value + +func init() { + currentLevel.Store(zerolog.GlobalLevel().String()) + + debug.Get("/log/level", func(ctx fiber.Ctx) error { + // JSON 格式响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + return ctx.JSON(fiber.Map{ + "level": currentLevel.Load(), + "available_levels": getAvailableLevels(), + }) + } + + // HTML 页面 + current := currentLevel.Load().(string) + levels := getAvailableLevels() + + // 构建级别选择器 + levelOptions := "" + for _, level := range levels { + selected := "" + if level == current { + selected = "selected" + } + levelColor := getLevelColor(level) + levelOptions += fmt.Sprintf(``, level, selected, levelColor, strings.ToUpper(level)) + } + + // 级别颜色映射 + levelBadges := template.HTML("") + for _, level := range levels { + active := "" + if level == current { + active = "ring-2 ring-white" + } + levelBadges += template.HTML(fmt.Sprintf(`%s`, + getLevelBgColor(level), active, level, strings.ToUpper(level))) + } + + content := template.HTML(fmt.Sprintf(` +
+ %s + %s + %s +
+
+
+

日志级别选择

+
+
+
%s
+
+
级别说明:
+
    +
  • TRACE - 最详细的追踪信息
  • +
  • DEBUG - 调试信息
  • +
  • INFO - 一般信息
  • +
  • WARN - 警告信息
  • +
  • ERROR - 错误信息
  • +
  • FATAL - 致命错误
  • +
  • PANIC - 严重错误
  • +
  • DISABLED - 禁用日志
  • +
+
+
+
+ +`, + ui.StatsCard("当前级别", strings.ToUpper(current), ""), + ui.StatsCard("可用级别", fmt.Sprintf("%d", len(levels)), ""), + ui.StatsCard("全局日志", "zerolog", ""), + levelBadges)) + + html, err := ui.Render(ui.PageData{ + Title: "日志级别管理", + Description: "动态调整应用日志级别", + Breadcrumb: []string{"Log", "Level"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Put("/log/level", func(ctx fiber.Ctx) error { + return setLogLevel(ctx) + }) + + debug.Post("/log/level", func(ctx fiber.Ctx) error { + return setLogLevel(ctx) + }) +} + +func setLogLevel(ctx fiber.Ctx) error { + type request struct { + Level string `json:"level" form:"level" query:"level"` + } + + var req request + if err := ctx.Bind().Body(&req); err != nil { + req.Level = ctx.Query("level") + } + + if req.Level == "" { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "level is required", + "available_levels": getAvailableLevels(), + }) + } + + level, err := zerolog.ParseLevel(strings.ToLower(req.Level)) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid log level: " + req.Level, + "available_levels": getAvailableLevels(), + }) + } + + oldLevel := currentLevel.Load() + zerolog.SetGlobalLevel(level) + currentLevel.Store(level.String()) + + log.Info(). + Str("old_level", oldLevel.(string)). + Str("new_level", level.String()). + Msg("log level changed") + + return ctx.JSON(fiber.Map{ + "success": true, + "old_level": oldLevel, + "new_level": level.String(), + }) +} + +func getAvailableLevels() []string { + return []string{ + zerolog.TraceLevel.String(), + zerolog.DebugLevel.String(), + zerolog.InfoLevel.String(), + zerolog.WarnLevel.String(), + zerolog.ErrorLevel.String(), + zerolog.FatalLevel.String(), + zerolog.PanicLevel.String(), + zerolog.Disabled.String(), + } +} + +func getLevelColor(level string) string { + colors := map[string]string{ + "trace": "text-purple-400", + "debug": "text-blue-400", + "info": "text-green-400", + "warn": "text-yellow-400", + "error": "text-red-400", + "fatal": "text-red-600", + "panic": "text-red-800", + "disabled": "text-gray-500", + } + if c, ok := colors[level]; ok { + return c + } + return "text-gray-400" +} + +func getLevelBgColor(level string) string { + colors := map[string]string{ + "trace": "bg-purple-500/20 text-purple-400", + "debug": "bg-blue-500/20 text-blue-400", + "info": "bg-green-500/20 text-green-400", + "warn": "bg-yellow-500/20 text-yellow-400", + "error": "bg-red-500/20 text-red-400", + "fatal": "bg-red-600/20 text-red-500", + "panic": "bg-red-800/20 text-red-600", + "disabled": "bg-gray-500/20 text-gray-500", + } + if c, ok := colors[level]; ok { + return c + } + return "bg-gray-500/20 text-gray-400" +} diff --git a/core/debug/mux.go b/core/debug/mux.go index 6860355b0..4d9c8c7bf 100644 --- a/core/debug/mux.go +++ b/core/debug/mux.go @@ -3,8 +3,9 @@ package debug import ( "net/http" - "github.com/gofiber/adaptor/v2" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" + "github.com/gofiber/fiber/v3/middleware/static" ) type Config struct { @@ -15,21 +16,45 @@ type Config struct { var app = fiber.New() -func App() *fiber.App { return app } -func WrapFunc(h http.HandlerFunc) fiber.Handler { return adaptor.HTTPHandlerFunc(h) } -func Wrap(h http.Handler) fiber.Handler { return adaptor.HTTPHandler(h) } -func Get(path string, handlers ...fiber.Handler) { app.Get(path, handlers...) } -func Head(path string, handlers ...fiber.Handler) { app.Head(path, handlers...) } -func Post(path string, handlers ...fiber.Handler) { app.Post(path, handlers...) } -func Put(path string, handlers ...fiber.Handler) { app.Put(path, handlers...) } -func Delete(path string, handlers ...fiber.Handler) { app.Delete(path, handlers...) } -func Connect(path string, handlers ...fiber.Handler) { app.Connect(path, handlers...) } -func Options(path string, handlers ...fiber.Handler) { app.Options(path, handlers...) } -func Trace(path string, handlers ...fiber.Handler) { app.Trace(path, handlers...) } -func Patch(path string, handlers ...fiber.Handler) { app.Patch(path, handlers...) } -func Static(prefix, root string, config ...fiber.Static) { app.Static(prefix, root, config...) } -func All(path string, handlers ...fiber.Handler) { app.All(path, handlers...) } -func Group(prefix string, handlers ...fiber.Handler) { app.Group(prefix, handlers...) } +func App() *fiber.App { return app } +func WrapFunc(h http.HandlerFunc) fiber.Handler { return adaptor.HTTPHandlerFunc(h) } +func Wrap(h http.Handler) fiber.Handler { return adaptor.HTTPHandler(h) } +func Get(path string, handler any, handlers ...any) { + app.Get(path, handler, handlers...) +} + +func Head(path string, handler any, handlers ...any) { + app.Head(path, handler, handlers...) +} + +func Post(path string, handler any, handlers ...any) { + app.Post(path, handler, handlers...) +} + +func Put(path string, handler any, handlers ...any) { + app.Put(path, handler, handlers...) +} + +func Delete(path string, handler any, handlers ...any) { + app.Delete(path, handler, handlers...) +} + +func Patch(path string, handler any, handlers ...any) { + app.Patch(path, handler, handlers...) +} + +func Static(prefix, root string, config ...static.Config) { + app.Use(prefix, static.New(root, config...)) +} + +func All(path string, handler any, handlers ...any) { + app.All(path, handler, handlers...) +} + +func Group(prefix string, handlers ...any) { + app.Group(prefix, handlers...) +} + func Route(prefix string, fn func(router fiber.Router), name ...string) { app.Route(prefix, fn, name...) } diff --git a/core/debug/pprof/pprof.go b/core/debug/pprof/pprof.go index 709ef18c0..fdebc2cbc 100644 --- a/core/debug/pprof/pprof.go +++ b/core/debug/pprof/pprof.go @@ -5,7 +5,7 @@ import ( "net/http/pprof" "github.com/felixge/fgprof" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/lava/v2/core/debug" ) @@ -14,7 +14,7 @@ func init() { debug.Get("/gprof/", debug.Wrap(fgprof.Handler())) debug.Route("/pprof/", func(r fiber.Router) { r.Get("", debug.WrapFunc(pprof.Index)) - r.Get(":name", func(ctx *fiber.Ctx) error { + r.Get(":name", func(ctx fiber.Ctx) error { name := ctx.Params("name") switch name { case "cmdline": diff --git a/core/debug/process/process.go b/core/debug/process/process.go index 9c67a53a6..b1b34b71c 100644 --- a/core/debug/process/process.go +++ b/core/debug/process/process.go @@ -3,7 +3,7 @@ package process import ( "debug/buildinfo" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" ps "github.com/keybase/go-ps" "github.com/pubgo/funk/v2" "github.com/pubgo/funk/v2/assert" @@ -14,7 +14,7 @@ import ( ) func init() { - debug.Get("/process", func(ctx *fiber.Ctx) (gErr error) { + debug.Get("/process", func(ctx fiber.Ctx) (gErr error) { defer result.RecoveryErr(&gErr) processes := assert.Must1(ps.Processes()) processes1 := funk.Map(processes, func(p ps.Process) map[string]any { diff --git a/core/debug/ratelimit/ratelimit.go b/core/debug/ratelimit/ratelimit.go new file mode 100644 index 000000000..405de3fec --- /dev/null +++ b/core/debug/ratelimit/ratelimit.go @@ -0,0 +1,158 @@ +package ratelimit + +import ( + "strconv" + "sync" + "time" + + "github.com/gofiber/fiber/v3" + + "github.com/pubgo/lava/v2/core/debug" +) + +var ( + enabled = false + limit = 100 + window = time.Minute + requests = make(map[string]*requestCounter) + requestsMu sync.RWMutex +) + +type requestCounter struct { + Count int + ResetTime time.Time +} + +func init() { + debug.App().Use(rateLimitMiddleware) + + debug.Get("/ratelimit", func(ctx fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "enabled": enabled, + "limit": limit, + "window": window.String(), + "window_seconds": window.Seconds(), + }) + }) + + debug.Post("/ratelimit/enable", func(ctx fiber.Ctx) error { + enabled = true + return ctx.JSON(fiber.Map{ + "success": true, + "enabled": enabled, + }) + }) + + debug.Post("/ratelimit/disable", func(ctx fiber.Ctx) error { + enabled = false + return ctx.JSON(fiber.Map{ + "success": true, + "enabled": enabled, + }) + }) + + debug.Put("/ratelimit/config", func(ctx fiber.Ctx) error { + type request struct { + Limit int `json:"limit" form:"limit" query:"limit"` + Window int `json:"window" form:"window" query:"window"` + } + + var req request + if err := ctx.Bind().Body(&req); err != nil { + if l, err := strconv.Atoi(ctx.Query("limit")); err == nil && l > 0 { + req.Limit = l + } + if w, err := strconv.Atoi(ctx.Query("window")); err == nil && w > 0 { + req.Window = w + } + } + + if req.Limit > 0 { + limit = req.Limit + } + if req.Window > 0 { + window = time.Duration(req.Window) * time.Second + } + + return ctx.JSON(fiber.Map{ + "success": true, + "limit": limit, + "window": window.String(), + "window_seconds": window.Seconds(), + }) + }) + + debug.Get("/ratelimit/stats", func(ctx fiber.Ctx) error { + requestsMu.RLock() + stats := make(map[string]fiber.Map) + for ip, counter := range requests { + stats[ip] = fiber.Map{ + "count": counter.Count, + "reset_time": counter.ResetTime.Format(time.RFC3339), + "remaining": limit - counter.Count, + } + } + requestsMu.RUnlock() + + return ctx.JSON(fiber.Map{ + "enabled": enabled, + "limit": limit, + "window": window.String(), + "active_users": len(stats), + "stats": stats, + }) + }) + + debug.Post("/ratelimit/reset", func(ctx fiber.Ctx) error { + requestsMu.Lock() + requests = make(map[string]*requestCounter) + requestsMu.Unlock() + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "rate limit stats cleared", + }) + }) +} + +func rateLimitMiddleware(ctx fiber.Ctx) error { + if !enabled { + return ctx.Next() + } + + ip := ctx.IP() + + requestsMu.Lock() + counter, exists := requests[ip] + if !exists || time.Now().After(counter.ResetTime) { + counter = &requestCounter{ + Count: 0, + ResetTime: time.Now().Add(window), + } + requests[ip] = counter + } + counter.Count++ + count := counter.Count + resetTime := counter.ResetTime + requestsMu.Unlock() + + remaining := limit - count + if remaining < 0 { + remaining = 0 + } + + ctx.Set("X-RateLimit-Limit", strconv.Itoa(limit)) + ctx.Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) + ctx.Set("X-RateLimit-Reset", resetTime.Format(time.RFC3339)) + + if count > limit { + return ctx.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "rate limit exceeded", + "limit": limit, + "remaining": 0, + "reset": resetTime.Format(time.RFC3339), + }) + } + + return ctx.Next() +} diff --git a/core/debug/runtime/runtime.go b/core/debug/runtime/runtime.go new file mode 100644 index 000000000..63d582d4d --- /dev/null +++ b/core/debug/runtime/runtime.go @@ -0,0 +1,395 @@ +package runtime + +import ( + "fmt" + "html/template" + "runtime" + rd "runtime/debug" + "runtime/metrics" + "sort" + "time" + + "github.com/gofiber/fiber/v3" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var runtimeStartTime = time.Now() + +func init() { + // Runtime 仪表板 HTML 页面 + debug.Get("/runtime", func(ctx fiber.Ctx) error { + // 如果请求 JSON 格式 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "go_version": runtime.Version(), + "go_os": runtime.GOOS, + "go_arch": runtime.GOARCH, + "num_cpu": runtime.NumCPU(), + "num_goroutine": runtime.NumGoroutine(), + "gomaxprocs": runtime.GOMAXPROCS(0), + "heap_alloc": m.HeapAlloc, + "heap_sys": m.HeapSys, + "uptime": time.Since(runtimeStartTime).String(), + }) + } + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + heapPercent := float64(m.HeapInuse) / float64(m.HeapSys) * 100 + heapColor := "green" + if heapPercent > 80 { + heapColor = "red" + } else if heapPercent > 60 { + heapColor = "yellow" + } + + // 构建统计卡片 + statsContent := ui.StatsCard("Go 版本", runtime.Version(), runtime.GOOS+"/"+runtime.GOARCH) + statsContent += ui.StatsCard("CPU", fmt.Sprintf("%d/%d", runtime.GOMAXPROCS(0), runtime.NumCPU()), "GOMAXPROCS/NumCPU") + statsContent += ui.StatsCard("Goroutines", fmt.Sprintf("%d", runtime.NumGoroutine()), "当前活跃") + statsContent += ui.StatsCard("Cgo 调用", fmt.Sprintf("%d", runtime.NumCgoCall()), "累计") + + // 内存卡片内容 + memContent := template.HTML(fmt.Sprintf(` +
+
+ 堆内存使用 + %s / %s +
+ %s +
+
+
Heap Alloc
+
%s
+
+
+
Heap Objects
+
%d
+
+
+
Stack Inuse
+
%s
+
+
+
Total Alloc
+
%s
+
+
+
`, ui.FormatBytes(m.HeapInuse), ui.FormatBytes(m.HeapSys), ui.ProgressBar(heapPercent, heapColor), + ui.FormatBytes(m.HeapAlloc), m.HeapObjects, ui.FormatBytes(m.StackInuse), ui.FormatBytes(m.TotalAlloc))) + + // GC 卡片内容 + gcContent := template.HTML(fmt.Sprintf(` +
+
+
GC 次数
+
%d
+
+
+
GC CPU
+
%.4f%%
+
+
+
上次 GC
+
%s
+
+
+
下次 GC 阈值
+
%s
+
+
`, m.NumGC, m.GCCPUFraction*100, time.Unix(0, int64(m.LastGC)).Format("15:04:05"), ui.FormatBytes(m.NextGC))) + + // 操作按钮 + actionsContent := template.HTML(` +
+ 内存详情 + GC 详情 + 所有指标 + + +
+`) + + // 组合内容 + content := template.HTML(fmt.Sprintf(` +
%s
+%s +
+ %s + %s +
+
+

运行时 %s

+
Sys: %s | Uptime: %s
+
`, + statsContent, + ui.CardWithAction("快捷操作", "", actionsContent), + ui.Card("内存状态", memContent), + ui.Card("GC 状态", gcContent), + time.Since(runtimeStartTime).Round(time.Second).String(), + ui.FormatBytes(m.Sys), + time.Since(runtimeStartTime).Round(time.Second).String())) + + html, err := ui.Render(ui.PageData{ + Title: "Runtime 监控", + Description: "Go 运行时状态和内存监控", + Breadcrumb: []string{"Runtime"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + // 获取所有 runtime/metrics 指标 + debug.Get("/runtime/metrics", func(ctx fiber.Ctx) error { + descs := metrics.All() + samples := make([]metrics.Sample, len(descs)) + for i := range descs { + samples[i].Name = descs[i].Name + } + metrics.Read(samples) + + result := make(map[string]any, len(samples)) + for _, sample := range samples { + result[sample.Name] = formatMetricValue(sample.Value) + } + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "metrics": result, + }) + }) + + // 内存统计 + debug.Get("/runtime/memory", func(ctx fiber.Ctx) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "memory": fiber.Map{ + "heap_alloc_bytes": m.HeapAlloc, + "heap_sys_bytes": m.HeapSys, + "heap_idle_bytes": m.HeapIdle, + "heap_inuse_bytes": m.HeapInuse, + "heap_released_bytes": m.HeapReleased, + "heap_objects": m.HeapObjects, + "stack_inuse_bytes": m.StackInuse, + "stack_sys_bytes": m.StackSys, + "alloc_bytes": m.Alloc, + "total_alloc_bytes": m.TotalAlloc, + "sys_bytes": m.Sys, + "mallocs": m.Mallocs, + "frees": m.Frees, + "gc_sys_bytes": m.GCSys, + "gc_next_bytes": m.NextGC, + "gc_num": m.NumGC, + "gc_cpu_fraction": m.GCCPUFraction, + }, + }) + }) + + // 运行时信息 + debug.Get("/runtime/info", func(ctx fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "go_version": runtime.Version(), + "go_os": runtime.GOOS, + "go_arch": runtime.GOARCH, + "num_cpu": runtime.NumCPU(), + "num_goroutine": runtime.NumGoroutine(), + "num_cgo_call": runtime.NumCgoCall(), + "gomaxprocs": runtime.GOMAXPROCS(0), + "uptime": time.Since(runtimeStartTime).String(), + "uptime_seconds": time.Since(runtimeStartTime).Seconds(), + }) + }) + + // GC 统计 + debug.Get("/runtime/gc", func(ctx fiber.Ctx) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "gc": fiber.Map{ + "num_gc": m.NumGC, + "num_forced_gc": m.NumForcedGC, + "gc_cpu_fraction": m.GCCPUFraction, + "pause_total_ns": m.PauseTotalNs, + "pause_total_ms": float64(m.PauseTotalNs) / 1e6, + "last_gc": time.Unix(0, int64(m.LastGC)).Format(time.RFC3339), + "next_gc_bytes": m.NextGC, + "recent_pauses": recentPauses(&m), + }, + }) + }) + + // 手动触发 GC + debug.Post("/runtime/gc/trigger", func(ctx fiber.Ctx) error { + before := runtime.NumGoroutine() + var mBefore runtime.MemStats + runtime.ReadMemStats(&mBefore) + + runtime.GC() + + var mAfter runtime.MemStats + runtime.ReadMemStats(&mAfter) + after := runtime.NumGoroutine() + + return ctx.JSON(fiber.Map{ + "success": true, + "timestamp": time.Now().Format(time.RFC3339), + "before": fiber.Map{ + "heap_alloc_bytes": mBefore.HeapAlloc, + "heap_objects": mBefore.HeapObjects, + "goroutines": before, + }, + "after": fiber.Map{ + "heap_alloc_bytes": mAfter.HeapAlloc, + "heap_objects": mAfter.HeapObjects, + "goroutines": after, + }, + "freed_bytes": int64(mBefore.HeapAlloc) - int64(mAfter.HeapAlloc), + }) + }) + + // 释放内存给操作系统 + debug.Post("/runtime/freemem", func(ctx fiber.Ctx) error { + var mBefore runtime.MemStats + runtime.ReadMemStats(&mBefore) + + runtime.GC() + rd.FreeOSMemory() + + var mAfter runtime.MemStats + runtime.ReadMemStats(&mAfter) + + return ctx.JSON(fiber.Map{ + "success": true, + "timestamp": time.Now().Format(time.RFC3339), + "before": fiber.Map{ + "heap_sys_bytes": mBefore.HeapSys, + "heap_released_bytes": mBefore.HeapReleased, + }, + "after": fiber.Map{ + "heap_sys_bytes": mAfter.HeapSys, + "heap_released_bytes": mAfter.HeapReleased, + }, + }) + }) + + // 可用的 metrics 描述 + debug.Get("/runtime/metrics/desc", func(ctx fiber.Ctx) error { + descs := metrics.All() + result := make([]fiber.Map, 0, len(descs)) + + for _, desc := range descs { + result = append(result, fiber.Map{ + "name": desc.Name, + "description": desc.Description, + "kind": kindString(desc.Kind), + "cumulative": desc.Cumulative, + }) + } + + sort.Slice(result, func(i, j int) bool { + return result[i]["name"].(string) < result[j]["name"].(string) + }) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "count": len(result), + "metrics": result, + }) + }) +} + +func formatMetricValue(v metrics.Value) any { + switch v.Kind() { + case metrics.KindUint64: + return v.Uint64() + case metrics.KindFloat64: + return v.Float64() + case metrics.KindFloat64Histogram: + h := v.Float64Histogram() + return fiber.Map{ + "buckets": h.Buckets, + "counts": h.Counts, + } + case metrics.KindBad: + return "bad metric" + default: + return "unknown" + } +} + +func kindString(k metrics.ValueKind) string { + switch k { + case metrics.KindUint64: + return "uint64" + case metrics.KindFloat64: + return "float64" + case metrics.KindFloat64Histogram: + return "float64_histogram" + case metrics.KindBad: + return "bad" + default: + return "unknown" + } +} + +func recentPauses(m *runtime.MemStats) []uint64 { + n := int(m.NumGC) + if n > 256 { + n = 256 + } + if n > 10 { + n = 10 + } + + pauses := make([]uint64, n) + for i := 0; i < n; i++ { + idx := (int(m.NumGC) - 1 - i) % 256 + pauses[i] = m.PauseNs[idx] + } + return pauses +} diff --git a/core/debug/statsviz/main.go b/core/debug/statsviz/main.go index 3935bdeb5..f50d299bf 100644 --- a/core/debug/statsviz/main.go +++ b/core/debug/statsviz/main.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/arl/statsviz" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2" "github.com/pubgo/funk/v2/assert" @@ -17,7 +17,7 @@ import ( func init() { srv := assert.Exit1(statsviz.NewServer(statsviz.Root("/debug/statsviz"))) debug.Route("/statsviz", func(router fiber.Router) { - router.Use(func(ctx *fiber.Ctx) error { + router.Use(func(ctx fiber.Ctx) error { path := string(ctx.Request().URI().Path()) lastPath := strings.TrimSpace(funk.Last(strings.Split(strings.Trim(path, "/"), "/"))) @@ -32,13 +32,13 @@ func init() { return ctx.Next() }) - router.Get("", func(ctx *fiber.Ctx) error { + router.Get("", func(ctx fiber.Ctx) error { return httputil.HTTPHandler(srv.Index())(ctx) }) - router.Get("/", func(ctx *fiber.Ctx) error { + router.Get("/", func(ctx fiber.Ctx) error { return httputil.HTTPHandler(srv.Index())(ctx) }) - router.Get("/*", func(ctx *fiber.Ctx) error { + router.Get("/*", func(ctx fiber.Ctx) error { return httputil.HTTPHandler(srv.Index())(ctx) }) }) diff --git a/core/debug/sysinfo/sysinfo.go b/core/debug/sysinfo/sysinfo.go new file mode 100644 index 000000000..e2a65037b --- /dev/null +++ b/core/debug/sysinfo/sysinfo.go @@ -0,0 +1,435 @@ +package sysinfo + +import ( + "fmt" + "html/template" + "os" + "runtime" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/load" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" + psprocess "github.com/shirou/gopsutil/v3/process" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" + "github.com/pubgo/lava/v2/core/running" +) + +var sysStartTime = time.Now() + +func init() { + // 系统信息仪表板 HTML 页面 + debug.Get("/sys", func(ctx fiber.Ctx) error { + vmem, _ := mem.VirtualMemory() + cpuPercent, _ := cpu.Percent(0, false) + loadAvg, _ := load.Avg() + hostInfo, _ := host.Info() + + // JSON 响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + pid := int32(os.Getpid()) + proc, _ := psprocess.NewProcess(pid) + procCPU, _ := proc.CPUPercent() + procMem, _ := proc.MemoryPercent() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "system": fiber.Map{ + "hostname": hostInfo.Hostname, + "platform": hostInfo.Platform, + "cpu_percent": cpuPercent, + "load_avg": loadAvg, + "memory_total": vmem.Total, + "memory_used": vmem.Used, + "memory_percent": vmem.UsedPercent, + }, + "process": fiber.Map{ + "pid": pid, + "cpu_percent": procCPU, + "memory_percent": procMem, + "goroutines": runtime.NumGoroutine(), + "heap_alloc": m.HeapAlloc, + }, + }) + } + + // HTML 页面 + partitions, _ := disk.Partitions(false) + + pid := int32(os.Getpid()) + proc, _ := psprocess.NewProcess(pid) + procCPU, _ := proc.CPUPercent() + procMem, _ := proc.MemoryPercent() + numThreads, _ := proc.NumThreads() + numFDs, _ := proc.NumFDs() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + // CPU 使用率 + cpuVal := float64(0) + if len(cpuPercent) > 0 { + cpuVal = cpuPercent[0] + } + cpuColor := "green" + if cpuVal > 80 { + cpuColor = "red" + } else if cpuVal > 60 { + cpuColor = "yellow" + } + + // 内存使用率 + memColor := "green" + if vmem.UsedPercent > 80 { + memColor = "red" + } else if vmem.UsedPercent > 60 { + memColor = "yellow" + } + + // 系统概览卡片 + statsContent := ui.StatsCard("主机名", hostInfo.Hostname, hostInfo.Platform+" "+hostInfo.PlatformVersion) + statsContent += ui.StatsCard("CPU", fmt.Sprintf("%.1f%%", cpuVal), fmt.Sprintf("%d 核心, 负载: %.2f", runtime.NumCPU(), loadAvg.Load1)) + statsContent += ui.StatsCard("内存", fmt.Sprintf("%.1f%%", vmem.UsedPercent), fmt.Sprintf("%s / %s", ui.FormatBytes(vmem.Used), ui.FormatBytes(vmem.Total))) + statsContent += ui.StatsCard("运行时间", time.Since(sysStartTime).Round(time.Second).String(), fmt.Sprintf("开机: %s", formatDuration(hostInfo.Uptime))) + + // CPU 详情 + cpuContent := template.HTML(fmt.Sprintf(` +
+
+ CPU 使用率 + %.1f%% +
+ %s +
+
+
1 分钟负载
+
%.2f
+
+
+
5 分钟负载
+
%.2f
+
+
+
15 分钟负载
+
%.2f
+
+
+
`, cpuVal, ui.ProgressBar(cpuVal, cpuColor), loadAvg.Load1, loadAvg.Load5, loadAvg.Load15)) + + // 内存详情 + memContent := template.HTML(fmt.Sprintf(` +
+
+ 内存使用率 + %s / %s (%.1f%%) +
+ %s +
+
+
可用内存
+
%s
+
+
+
缓冲/缓存
+
%s
+
+
+
`, ui.FormatBytes(vmem.Used), ui.FormatBytes(vmem.Total), vmem.UsedPercent, + ui.ProgressBar(vmem.UsedPercent, memColor), + ui.FormatBytes(vmem.Available), ui.FormatBytes(vmem.Cached))) + + // 进程信息 + processContent := template.HTML(fmt.Sprintf(` +
+
+
PID
+
%d
+
+
+
CPU
+
%.1f%%
+
+
+
内存
+
%.1f%%
+
+
+
Goroutines
+
%d
+
+
+
线程
+
%d
+
+
+
文件描述符
+
%d
+
+
+
堆内存
+
%s
+
+
+
GC 次数
+
%d
+
+
`, pid, procCPU, procMem, runtime.NumGoroutine(), numThreads, numFDs, ui.FormatBytes(m.HeapAlloc), m.NumGC)) + + // 磁盘信息 + diskContent := template.HTML("") + for _, p := range partitions { + usage, err := disk.Usage(p.Mountpoint) + if err != nil { + continue + } + diskColor := "green" + if usage.UsedPercent > 90 { + diskColor = "red" + } else if usage.UsedPercent > 70 { + diskColor = "yellow" + } + diskContent += template.HTML(fmt.Sprintf(` +
+
+ %s + %s +
+
%s / %s (%.1f%%)
+ %s +
`, p.Mountpoint, p.Fstype, ui.FormatBytes(usage.Used), ui.FormatBytes(usage.Total), usage.UsedPercent, ui.ProgressBar(usage.UsedPercent, diskColor))) + } + if diskContent == "" { + diskContent = template.HTML(`
无磁盘信息
`) + } + + // 快速操作 + actionsContent := template.HTML(` +`) + + // 组合内容 + content := template.HTML(fmt.Sprintf(` +
%s
+%s +
+ %s + %s +
+%s +%s`, + statsContent, + ui.Card("快速操作", actionsContent), + ui.Card("CPU 状态", cpuContent), + ui.Card("内存状态", memContent), + ui.Card("当前进程", processContent), + ui.Card("磁盘使用", diskContent))) + + html, err := ui.Render(ui.PageData{ + Title: "系统信息", + Description: "系统资源监控和进程信息", + Breadcrumb: []string{"System"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Get("/sys/info", func(ctx fiber.Ctx) error { + hostInfo, _ := host.Info() + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "host": hostInfo, + "go": fiber.Map{ + "version": runtime.Version(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "num_cpu": runtime.NumCPU(), + "gomaxprocs": runtime.GOMAXPROCS(0), + "num_goroutine": runtime.NumGoroutine(), + }, + "app": fiber.Map{ + "project": running.Project(), + "version": running.Version(), + "hostname": running.Hostname, + "instance_id": running.InstanceID, + "uptime": time.Since(sysStartTime).String(), + }, + }) + }) + + debug.Get("/sys/cpu", func(ctx fiber.Ctx) error { + cpuInfo, _ := cpu.Info() + cpuPercent, _ := cpu.Percent(time.Second, false) + cpuTimes, _ := cpu.Times(false) + loadAvg, _ := load.Avg() + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "info": cpuInfo, + "percent": cpuPercent, + "times": cpuTimes, + "load_avg": loadAvg, + "num_cpu": runtime.NumCPU(), + "gomaxprocs": runtime.GOMAXPROCS(0), + }) + }) + + debug.Get("/sys/memory", func(ctx fiber.Ctx) error { + vmem, _ := mem.VirtualMemory() + swap, _ := mem.SwapMemory() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "virtual": vmem, + "swap": swap, + "go_heap": fiber.Map{ + "alloc_bytes": m.HeapAlloc, + "sys_bytes": m.HeapSys, + "idle_bytes": m.HeapIdle, + "inuse_bytes": m.HeapInuse, + "objects": m.HeapObjects, + }, + }) + }) + + debug.Get("/sys/disk", func(ctx fiber.Ctx) error { + partitions, _ := disk.Partitions(false) + var diskUsages []fiber.Map + for _, p := range partitions { + usage, err := disk.Usage(p.Mountpoint) + if err == nil { + diskUsages = append(diskUsages, fiber.Map{ + "device": p.Device, + "mountpoint": p.Mountpoint, + "fstype": p.Fstype, + "total": usage.Total, + "used": usage.Used, + "free": usage.Free, + "percent": usage.UsedPercent, + }) + } + } + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "disks": diskUsages, + }) + }) + + debug.Get("/sys/network", func(ctx fiber.Ctx) error { + interfaces, _ := net.Interfaces() + ioCounters, _ := net.IOCounters(true) + connections, _ := net.Connections("all") + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "interfaces": interfaces, + "io_counters": ioCounters, + "connection_count": len(connections), + }) + }) + + debug.Get("/sys/process", func(ctx fiber.Ctx) error { + pid := int32(os.Getpid()) + proc, err := psprocess.NewProcess(pid) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + cpuPercent, _ := proc.CPUPercent() + memInfo, _ := proc.MemoryInfo() + memPercent, _ := proc.MemoryPercent() + numThreads, _ := proc.NumThreads() + numFDs, _ := proc.NumFDs() + ioCounters, _ := proc.IOCounters() + connections, _ := proc.Connections() + openFiles, _ := proc.OpenFiles() + createTime, _ := proc.CreateTime() + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "pid": pid, + "cpu_percent": cpuPercent, + "memory_info": memInfo, + "memory_percent": memPercent, + "num_threads": numThreads, + "num_fds": numFDs, + "io_counters": ioCounters, + "connections": len(connections), + "open_files": len(openFiles), + "create_time": time.UnixMilli(createTime).Format(time.RFC3339), + "uptime": time.Since(sysStartTime).String(), + }) + }) + + debug.Get("/sys/summary", func(ctx fiber.Ctx) error { + vmem, _ := mem.VirtualMemory() + cpuPercent, _ := cpu.Percent(0, false) + loadAvg, _ := load.Avg() + hostInfo, _ := host.Info() + + pid := int32(os.Getpid()) + proc, _ := psprocess.NewProcess(pid) + procCPU, _ := proc.CPUPercent() + procMem, _ := proc.MemoryPercent() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "system": fiber.Map{ + "hostname": hostInfo.Hostname, + "os": hostInfo.OS, + "platform": hostInfo.Platform, + "cpu_percent": cpuPercent, + "load_avg": loadAvg, + "memory_total": vmem.Total, + "memory_used": vmem.Used, + "memory_percent": vmem.UsedPercent, + }, + "process": fiber.Map{ + "pid": pid, + "cpu_percent": procCPU, + "memory_percent": procMem, + "goroutines": runtime.NumGoroutine(), + "heap_alloc": m.HeapAlloc, + "uptime": time.Since(sysStartTime).String(), + }, + }) + }) +} + +func formatDuration(seconds uint64) string { + d := time.Duration(seconds) * time.Second + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} diff --git a/core/debug/trace/trace.go b/core/debug/trace/trace.go index 8c02555f0..740dd76c6 100644 --- a/core/debug/trace/trace.go +++ b/core/debug/trace/trace.go @@ -1,7 +1,7 @@ package trace import ( - "github.com/gofiber/adaptor/v2" + "github.com/gofiber/fiber/v3/middleware/adaptor" "golang.org/x/net/trace" "github.com/pubgo/lava/v2/core/debug" diff --git a/core/debug/ui/ui.go b/core/debug/ui/ui.go new file mode 100644 index 000000000..975502cba --- /dev/null +++ b/core/debug/ui/ui.go @@ -0,0 +1,268 @@ +package ui + +import ( + "bytes" + "fmt" + "html/template" +) + +// BaseTemplate 基础 HTML 模板 +const BaseTemplate = ` + + + + + {{.Title}} - Debug Console + + + + {{if .ExtraHead}}{{.ExtraHead}}{{end}} + + + +
+ {{if .Breadcrumb}} + + {{end}} +
+

{{.Title}}

+ {{if .Description}}

{{.Description}}

{{end}} +
+ {{.Content}} +
+ + +` + +// PageData 页面数据 +type PageData struct { + Title string + Description string + Breadcrumb []string + Content template.HTML + ExtraHead template.HTML +} + +// Render 渲染基础模板 +func Render(data PageData) (string, error) { + tmpl, err := template.New("base").Parse(BaseTemplate) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +// Card 渲染卡片 +func Card(title string, content template.HTML) template.HTML { + return template.HTML(fmt.Sprintf(` +
+
+

%s

+
+
%s
+
`, title, content)) +} + +// CardWithAction 带操作按钮的卡片 +func CardWithAction(title string, actions, content template.HTML) template.HTML { + return template.HTML(fmt.Sprintf(` +
+
+

%s

+
%s
+
+
%s
+
`, title, actions, content)) +} + +// StatsCard 统计卡片 +func StatsCard(label, value, subtext string) template.HTML { + sub := "" + if subtext != "" { + sub = fmt.Sprintf(`
%s
`, subtext) + } + return template.HTML(fmt.Sprintf(` +
+
%s
+
%s
+ %s +
`, label, value, sub)) +} + +// Badge 徽章 +func Badge(text, color string) template.HTML { + colorClass := map[string]string{ + "green": "bg-green-500/20 text-green-400", + "red": "bg-red-500/20 text-red-400", + "yellow": "bg-yellow-500/20 text-yellow-400", + "blue": "bg-blue-500/20 text-blue-400", + "gray": "bg-gray-500/20 text-gray-400", + "purple": "bg-purple-500/20 text-purple-400", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["gray"] + } + return template.HTML(fmt.Sprintf(`%s`, cls, text)) +} + +// Button 按钮 +func Button(text, onclick, color string) template.HTML { + colorClass := map[string]string{ + "blue": "bg-blue-600 hover:bg-blue-700", + "green": "bg-green-600 hover:bg-green-700", + "red": "bg-red-600 hover:bg-red-700", + "yellow": "bg-yellow-600 hover:bg-yellow-700", + "gray": "bg-gray-600 hover:bg-gray-700", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["blue"] + } + return template.HTML(fmt.Sprintf(``, onclick, cls, text)) +} + +// Link 链接按钮 +func Link(text, href, color string) template.HTML { + colorClass := map[string]string{ + "blue": "text-blue-400 hover:text-blue-300", + "green": "text-green-400 hover:text-green-300", + "gray": "text-gray-400 hover:text-gray-300", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["blue"] + } + return template.HTML(fmt.Sprintf(`%s`, href, cls, text)) +} + +// Table 表格开始 +func Table(headers []string) template.HTML { + var h string + for _, header := range headers { + h += fmt.Sprintf(`%s`, header) + } + return template.HTML(fmt.Sprintf(` +
+ + %s + `, h)) +} + +// TableEnd 表格结束 +func TableEnd() template.HTML { + return template.HTML(`
`) +} + +// TR 表格行 +func TR(cells ...string) template.HTML { + var c string + for _, cell := range cells { + c += fmt.Sprintf(`%s`, cell) + } + return template.HTML(fmt.Sprintf(`%s`, c)) +} + +// Grid 网格布局 +func Grid(cols int, content template.HTML) template.HTML { + return template.HTML(fmt.Sprintf(`
%s
`, cols, content)) +} + +// JSONBlock JSON 代码块 +func JSONBlock(id string) template.HTML { + return template.HTML(fmt.Sprintf(`
`, id))
+}
+
+// FormatBytes 格式化字节
+func FormatBytes(b uint64) string {
+	const unit = 1024
+	if b < unit {
+		return fmt.Sprintf("%d B", b)
+	}
+	div, exp := uint64(unit), 0
+	for n := b / unit; n >= unit; n /= unit {
+		div *= unit
+		exp++
+	}
+	return fmt.Sprintf("%.2f %s", float64(b)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
+}
+
+// FormatPercent 格式化百分比
+func FormatPercent(p float64) string {
+	return fmt.Sprintf("%.1f%%", p)
+}
+
+// ProgressBar 进度条
+func ProgressBar(percent float64, color string) template.HTML {
+	colorClass := map[string]string{
+		"green":  "bg-green-500",
+		"red":    "bg-red-500",
+		"yellow": "bg-yellow-500",
+		"blue":   "bg-blue-500",
+	}
+	cls := colorClass[color]
+	if cls == "" {
+		cls = colorClass["blue"]
+	}
+	return template.HTML(fmt.Sprintf(`
+
+
+
`, cls, percent)) +} + +// Alert 警告框 +func Alert(text, color string) template.HTML { + colorClass := map[string]string{ + "green": "bg-green-500/10 border-green-500/50 text-green-400", + "red": "bg-red-500/10 border-red-500/50 text-red-400", + "yellow": "bg-yellow-500/10 border-yellow-500/50 text-yellow-400", + "blue": "bg-blue-500/10 border-blue-500/50 text-blue-400", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["blue"] + } + return template.HTML(fmt.Sprintf(`
%s
`, cls, text)) +} diff --git a/core/debug/vars/debug.go b/core/debug/vars/debug.go index b7666e33a..e349e8c7f 100644 --- a/core/debug/vars/debug.go +++ b/core/debug/vars/debug.go @@ -3,43 +3,279 @@ package vars import ( "expvar" "fmt" + "html/template" + "sort" - "github.com/gofiber/fiber/v2" - g "github.com/maragudk/gomponents" - c "github.com/maragudk/gomponents/components" - h "github.com/maragudk/gomponents/html" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" ) func init() { defer recovery.Exit() - index := func(keys []string) g.Node { - var nodes []g.Node - nodes = append(nodes, h.H1(g.Text("/expvar"))) - nodes = append(nodes, h.A(g.Text("/debug"), g.Attr("href", "/debug")), h.Br()) - for i := range keys { - nodes = append(nodes, h.A(g.Text(keys[i]), g.Attr("href", keys[i])), h.Br()) - } - return c.HTML5(c.HTML5Props{Title: "/expvar", Body: nodes}) - } debug.Route("/vars", func(r fiber.Router) { - r.Get("/", func(ctx *fiber.Ctx) error { - ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - var keys []string - expvar.Do(func(kv expvar.KeyValue) { - keys = append(keys, fmt.Sprintf("/debug/vars/%s", kv.Key)) - }) - - return index(keys).Render(ctx) + r.Get("/", handleVarsPage) + r.Get("/api/list", handleVarsList) + r.Get("/api/get/:name", handleVarGet) + r.Get("/:name", handleVarDetail) + }) +} + +func handleVarsPage(ctx fiber.Ctx) error { + var vars []varInfo + expvar.Do(func(kv expvar.KeyValue) { + vars = append(vars, varInfo{ + Name: kv.Key, + Value: kv.Value.String(), }) + }) + sort.Slice(vars, func(i, j int) bool { + return vars[i].Name < vars[j].Name + }) + + content := buildVarsContent(vars) + + html, err := ui.Render(ui.PageData{ + Title: "Expvar 变量", + Description: "应用运行时导出的变量", + Breadcrumb: []string{"Vars"}, + Content: template.HTML(content), + ExtraHead: template.HTML(buildVarsScript()), + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) +} - r.Get("/:name", func(ctx *fiber.Ctx) error { - name := ctx.Params("name") - ctx.Response().Header.Set("Content-Type", "application/json; charset=utf-8") - return ctx.SendString(expvar.Get(name).String()) +type varInfo struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func handleVarsList(ctx fiber.Ctx) error { + var vars []varInfo + expvar.Do(func(kv expvar.KeyValue) { + vars = append(vars, varInfo{ + Name: kv.Key, + Value: kv.Value.String(), }) }) + return ctx.JSON(vars) +} + +func handleVarGet(ctx fiber.Ctx) error { + name := ctx.Params("name") + v := expvar.Get(name) + if v == nil { + return ctx.Status(404).JSON(fiber.Map{"error": "variable not found"}) + } + ctx.Set("Content-Type", "application/json; charset=utf-8") + return ctx.SendString(v.String()) +} + +func handleVarDetail(ctx fiber.Ctx) error { + name := ctx.Params("name") + v := expvar.Get(name) + if v == nil { + return ctx.Status(404).SendString("variable not found") + } + ctx.Set("Content-Type", "application/json; charset=utf-8") + return ctx.SendString(v.String()) +} + +func buildVarsScript() string { + return ` + +` +} + +func buildVarsContent(vars []varInfo) string { + return fmt.Sprintf(` +
+ +
+
+
总变量数
+
%d
+
+
+
筛选结果
+
+
+
+
刷新
+ +
+
+ + +
+ +
+ + +
+ +
+
+

变量列表

+
+
+ +
+ 暂无匹配的变量 +
+
+
+ + +
+
+
+

变量详情

+ +
+ +
+
+ + +
+
+
+
+`, len(vars)) } diff --git a/core/debug/version/version.go b/core/debug/version/version.go index b27d96e61..80cb0c497 100644 --- a/core/debug/version/version.go +++ b/core/debug/version/version.go @@ -1,16 +1,16 @@ package version import ( + "encoding/json" "net/http" "os" rd "runtime/debug" - json "github.com/goccy/go-json" - "github.com/gofiber/adaptor/v2" + "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/pubgo/funk/v2/assert" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/running" ) func init() { diff --git a/core/debug/zpags/debug.go b/core/debug/zpags/debug.go index fc1a98b16..9c52c2950 100644 --- a/core/debug/zpags/debug.go +++ b/core/debug/zpags/debug.go @@ -1,7 +1,7 @@ package zpags import ( - "github.com/gofiber/adaptor/v2" + "github.com/gofiber/fiber/v3/middleware/adaptor" "go.opentelemetry.io/contrib/zpages" "github.com/pubgo/lava/v2/core/debug" diff --git a/core/discovery/aaa.go b/core/discovery/aaa.go index 7e70280e1..a268d2789 100644 --- a/core/discovery/aaa.go +++ b/core/discovery/aaa.go @@ -7,7 +7,6 @@ import ( "github.com/pubgo/funk/v2/result" "github.com/pubgo/lava/v2/core/service" - "github.com/pubgo/lava/v2/pkg/proto/lavapbv1" ) type ( @@ -32,7 +31,7 @@ type Watcher interface { // Result is returned by a call to Next on // the watcher. Actions can be create, update, delete type Result struct { - Action lavapbv1.EventType + Action EventType Service *service.Service } @@ -43,3 +42,12 @@ type WatchOpts struct { type GetOpts struct { Timeout time.Duration } + +type EventType int32 + +const ( + EventType_UNKNOWN EventType = 0 + EventType_CREATE EventType = 1 + EventType_UPDATE EventType = 2 + EventType_DELETE EventType = 3 +) diff --git a/core/flags/default.go b/core/flags/default.go index 7341d62a9..03e46dba0 100644 --- a/core/flags/default.go +++ b/core/flags/default.go @@ -1,13 +1,15 @@ package flags import ( - "github.com/pubgo/funk/v2/running" + "github.com/pubgo/funk/v2/config/configflags" + + "github.com/pubgo/lava/v2/core/running" ) func init() { Register(running.DebugFlag) Register(running.EnvFlag) - Register(running.ConfFlag) + Register(configflags.ConfFlag) Register(running.GrpcPortFlag) Register(running.HttpPortFlag) } diff --git a/core/healthy/aaa.go b/core/healthy/aaa.go index 354949859..97e127898 100644 --- a/core/healthy/aaa.go +++ b/core/healthy/aaa.go @@ -1,7 +1,7 @@ package healthy import ( - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" ) -type Handler func(req *fiber.Ctx) error +type Handler func(req fiber.Ctx) error diff --git a/core/lavabuilder/builder.go b/core/lavabuilder/builder.go index e6d127880..191f6e0ab 100644 --- a/core/lavabuilder/builder.go +++ b/core/lavabuilder/builder.go @@ -19,13 +19,22 @@ import ( "github.com/pubgo/lava/v2/cmds/healthcmd" "github.com/pubgo/lava/v2/cmds/httpservercmd" "github.com/pubgo/lava/v2/cmds/schedulercmd" + "github.com/pubgo/lava/v2/cmds/tunnelcmd" "github.com/pubgo/lava/v2/cmds/versioncmd" + _ "github.com/pubgo/lava/v2/core/debug/configview" _ "github.com/pubgo/lava/v2/core/debug/debug" "github.com/pubgo/lava/v2/core/debug/dixdebug" + _ "github.com/pubgo/lava/v2/core/debug/featurehttp" //_ "github.com/pubgo/lava/v2/core/debug/gops" + _ "github.com/pubgo/lava/v2/core/debug/goroutine" + _ "github.com/pubgo/lava/v2/core/debug/healthy" + _ "github.com/pubgo/lava/v2/core/debug/loglevel" _ "github.com/pubgo/lava/v2/core/debug/pprof" _ "github.com/pubgo/lava/v2/core/debug/process" + _ "github.com/pubgo/lava/v2/core/debug/ratelimit" + _ "github.com/pubgo/lava/v2/core/debug/runtime" _ "github.com/pubgo/lava/v2/core/debug/statsviz" + _ "github.com/pubgo/lava/v2/core/debug/sysinfo" _ "github.com/pubgo/lava/v2/core/debug/trace" _ "github.com/pubgo/lava/v2/core/debug/vars" _ "github.com/pubgo/lava/v2/core/debug/version" @@ -40,6 +49,8 @@ import ( _ "github.com/pubgo/lava/v2/core/logging/logext/grpclog" _ "github.com/pubgo/lava/v2/core/logging/logext/slog" _ "github.com/pubgo/lava/v2/core/logging/logext/stdlog" + // loggerdebug + _ "github.com/pubgo/lava/v2/core/logging/loggerdebug" _ "github.com/pubgo/lava/v2/core/metrics/drivers/prometheus" "github.com/pubgo/lava/v2/core/metrics/metricbuilder" "github.com/pubgo/lava/v2/core/signals" @@ -81,6 +92,7 @@ func Run(di *dix.Dix) { dix.Provide(di, grpcservercmd.New) dix.Provide(di, httpservercmd.New) dix.Provide(di, schedulercmd.New) + dix.Provide(di, tunnelcmd.New) dix.Inject(di, func(commands []*redant.Command) { app := &redant.Command{ Use: version.Project(), diff --git a/core/logging/config.go b/core/logging/config.go index 7ad69e4e8..a43a3485d 100644 --- a/core/logging/config.go +++ b/core/logging/config.go @@ -1,12 +1,136 @@ package logging +import "fmt" + +// LogConfigLoader 配置加载器 type LogConfigLoader struct { Log *Config `yaml:"logger"` } +// Config 日志配置 type Config struct { - Level string `yaml:"level"` - AsJson bool `yaml:"as_json"` + // Level 日志级别: trace, debug, info, warn, error, fatal, panic + Level string `yaml:"level"` + // AsJson 终端是否以 JSON 格式输出 + AsJson bool `yaml:"as_json"` + // DisableLoggers 要禁用日志输出的 logger 名称列表 + // 支持前缀匹配,如 "grpc" 会禁用 "grpc", "grpc.server" 等 DisableLoggers []string `yaml:"disable_loggers"` - Filters []string `yaml:"filters"` + // Filters expr 表达式过滤器列表,多个表达式以 && 连接 + Filters []string `yaml:"filters"` + // File 文件输出配置,为空则不输出到文件 + File *FileConfig `yaml:"file"` +} + +// FileConfig 文件输出配置 +type FileConfig struct { + // Enabled 是否启用文件输出 + Enabled bool `yaml:"enabled"` + // Path 日志文件路径 + Path string `yaml:"path"` + // MaxSize 单个日志文件最大大小(MB) + MaxSize int `yaml:"max_size"` + // MaxBackups 保留的旧日志文件最大数量 + MaxBackups int `yaml:"max_backups"` + // MaxAge 保留旧日志文件的最大天数 + MaxAge int `yaml:"max_age"` + // Compress 是否压缩旧日志文件 + Compress bool `yaml:"compress"` +} + +// DefaultFileConfig 返回默认文件配置 +func DefaultFileConfig() *FileConfig { + return &FileConfig{ + Enabled: false, + Path: "logs/app.log", + MaxSize: 100, // 100MB + MaxBackups: 10, + MaxAge: 30, // 30 天 + Compress: true, + } +} + +// DefaultConfig 返回默认配置 +func DefaultConfig() *Config { + return &Config{ + Level: "info", + AsJson: false, + File: DefaultFileConfig(), + } +} + +// Validate 验证配置有效性 +func (c *Config) Validate() error { + if c == nil { + return nil + } + + validLevels := map[string]bool{ + "": true, // 空字符串使用默认值 + "trace": true, + "debug": true, + "info": true, + "warn": true, + "error": true, + "fatal": true, + "panic": true, + } + + if !validLevels[c.Level] { + return fmt.Errorf("invalid log level: %s", c.Level) + } + + // 验证文件配置 + if c.File != nil && c.File.Enabled { + if c.File.Path == "" { + return fmt.Errorf("file path is required when file output is enabled") + } + if c.File.MaxSize <= 0 { + c.File.MaxSize = 100 + } + if c.File.MaxBackups <= 0 { + c.File.MaxBackups = 10 + } + if c.File.MaxAge <= 0 { + c.File.MaxAge = 30 + } + } + + return nil +} + +// Merge 合并配置,优先使用 other 的非零值 +func (c *Config) Merge(other *Config) *Config { + if other == nil { + return c + } + if c == nil { + return other + } + + result := *c + if other.Level != "" { + result.Level = other.Level + } + if other.AsJson { + result.AsJson = other.AsJson + } + if len(other.DisableLoggers) > 0 { + result.DisableLoggers = other.DisableLoggers + } + if len(other.Filters) > 0 { + result.Filters = other.Filters + } + if other.File != nil { + result.File = other.File + } + return &result +} + +// GetLogFilePath 获取日志文件路径(用于 loggerdebug 读取) +func (c *Config) GetLogFilePath() string { + if c.File != nil && c.File.Enabled { + return c.File.Path + } + return "" } diff --git a/core/logging/config_test.go b/core/logging/config_test.go new file mode 100644 index 000000000..b6a7fb1d8 --- /dev/null +++ b/core/logging/config_test.go @@ -0,0 +1,108 @@ +package logging + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, "info", cfg.Level) + assert.False(t, cfg.AsJson) + assert.Nil(t, cfg.DisableLoggers) + assert.Nil(t, cfg.Filters) +} + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + level string + wantErr bool + }{ + {"empty level", "", false}, + {"debug level", "debug", false}, + {"info level", "info", false}, + {"warn level", "warn", false}, + {"error level", "error", false}, + {"invalid level", "invalid", true}, + {"uppercase level", "INFO", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{Level: tt.level} + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + + // nil config should not error + var nilCfg *Config + assert.NoError(t, nilCfg.Validate()) +} + +func TestConfigMerge(t *testing.T) { + base := &Config{ + Level: "debug", + AsJson: false, + } + + override := &Config{ + Level: "info", + AsJson: true, + DisableLoggers: []string{"grpcLog"}, + } + + merged := base.Merge(override) + + assert.Equal(t, "info", merged.Level) + assert.True(t, merged.AsJson) + assert.Equal(t, []string{"grpcLog"}, merged.DisableLoggers) + + // nil merge tests + assert.Equal(t, base, base.Merge(nil)) + + var nilCfg *Config + assert.Equal(t, override, nilCfg.Merge(override)) +} + +func TestDisabledLoggers(t *testing.T) { + // Reset state + SetDisabledLoggers(nil) + + assert.False(t, IsDisabled("grpc")) + + SetDisabledLoggers([]string{"grpc", "std"}) + + // 精确匹配 + assert.True(t, IsDisabled("grpc")) + assert.True(t, IsDisabled("std")) + assert.False(t, IsDisabled("slog")) + + // 前缀匹配 + assert.True(t, IsDisabled("grpc.server")) + assert.True(t, IsDisabled("grpc.client")) + assert.True(t, IsDisabled("std.log")) + assert.False(t, IsDisabled("grpclog")) // 不是前缀匹配(没有点分隔) + assert.False(t, IsDisabled("stdlib")) // 不是前缀匹配 + + // Clear + SetDisabledLoggers(nil) + assert.False(t, IsDisabled("grpc")) + assert.False(t, IsDisabled("grpc.server")) +} + +func TestFactoryRegisterAndList(t *testing.T) { + // Note: factories are global state, be careful with tests + initialCount := len(List()) + + // Register should work (but we can't easily test without affecting global state) + factories := List() + assert.NotNil(t, factories) + assert.GreaterOrEqual(t, len(factories), initialCount) +} diff --git a/core/logging/factory.go b/core/logging/factory.go index 134740f3d..12d9128df 100644 --- a/core/logging/factory.go +++ b/core/logging/factory.go @@ -1,6 +1,8 @@ package logging import ( + "sync" + "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" @@ -8,12 +10,80 @@ import ( type Factory func(log log.Logger) -var factories = make(map[string]Factory) +var ( + factories = make(map[string]Factory) + factoryMu sync.RWMutex + disabledLoggers = make(map[string]struct{}) + logFilePath string // 日志文件路径,供 loggerdebug 使用 +) + +// List 返回所有注册的日志工厂(返回副本,线程安全) +func List() map[string]Factory { + factoryMu.RLock() + defer factoryMu.RUnlock() + + result := make(map[string]Factory, len(factories)) + for k, v := range factories { + result[k] = v + } + return result +} -func List() map[string]Factory { return factories } +// Register 注册日志工厂 func Register(name string, factory Factory) { defer recovery.Exit() assert.If(name == "" || factory == nil, "[factory, name] should not be null") + + factoryMu.Lock() + defer factoryMu.Unlock() + assert.If(factories[name] != nil, "[factory] %s already exists", name) factories[name] = factory } + +// SetDisabledLoggers 设置要禁用的 logger 名称列表 +// 这些 logger 的日志输出将被 EnableChecker 过滤掉 +func SetDisabledLoggers(names []string) { + factoryMu.Lock() + defer factoryMu.Unlock() + + disabledLoggers = make(map[string]struct{}, len(names)) + for _, name := range names { + disabledLoggers[name] = struct{}{} + } +} + +// IsDisabled 检查指定的 logger 是否被禁用 +// 支持前缀匹配,如禁用 "grpc" 会同时禁用 "grpc", "grpc.server", "grpc.client" 等 +func IsDisabled(name string) bool { + factoryMu.RLock() + defer factoryMu.RUnlock() + + // 精确匹配 + if _, ok := disabledLoggers[name]; ok { + return true + } + + // 前缀匹配 + for disabled := range disabledLoggers { + if len(name) > len(disabled) && name[:len(disabled)] == disabled && name[len(disabled)] == '.' { + return true + } + } + + return false +} + +// SetLogFilePath 设置日志文件路径 +func SetLogFilePath(path string) { + factoryMu.Lock() + defer factoryMu.Unlock() + logFilePath = path +} + +// GetLogFilePath 获取日志文件路径 +func GetLogFilePath() string { + factoryMu.RLock() + defer factoryMu.RUnlock() + return logFilePath +} diff --git a/core/logging/logbuilder/builder.go b/core/logging/logbuilder/builder.go index 1b428f453..7e2149458 100644 --- a/core/logging/logbuilder/builder.go +++ b/core/logging/logbuilder/builder.go @@ -2,51 +2,86 @@ package logbuilder import ( "context" + "fmt" "io" "os" + "path/filepath" "strings" "time" "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/funk/v2/features" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/pretty" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" - "github.com/pubgo/funk/v2/running" "github.com/rs/zerolog" "github.com/samber/lo" + "gopkg.in/natefinch/lumberjack.v2" "github.com/pubgo/lava/v2/core/logging" "github.com/pubgo/lava/v2/core/logging/logkey" + "github.com/pubgo/lava/v2/core/running" ) var GlobalHook zerolog.Hook -// New logger +// ConsoleLogEnabled 控制终端日志输出的开关 +var ConsoleLogEnabled = features.Bool("log.console.enabled", true, "是否启用终端日志输出") + +// New 创建新的 logger 实例 func New(cfg *logging.Config, hooks []zerolog.Hook) log.Logger { defer recovery.Exit(func(err error) error { pretty.Println(cfg) return err }) + // 配置验证 + if err := cfg.Validate(); err != nil { + assert.Exit(err, "invalid logging config") + } + + // 设置禁用的 loggers + if len(cfg.DisableLoggers) > 0 { + logging.SetDisabledLoggers(cfg.DisableLoggers) + } + level := zerolog.DebugLevel if cfg.Level != "" { level = result.Wrap(zerolog.ParseLevel(cfg.Level)).Expect("log level is invalid") } zerolog.SetGlobalLevel(level) - logger := zerolog.New(&writer{os.Stdout}).Level(level).With().Timestamp().Caller().Logger() - if !cfg.AsJson { - logger = logger.Output(&writer{ - zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { - w.Out = os.Stdout - w.TimeFormat = time.RFC3339 - }), + // 构建输出 writers + var writers []io.Writer + + // 终端输出(受 feature flag 控制) + var consoleWriter io.Writer + if cfg.AsJson { + consoleWriter = os.Stdout + } else { + consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.Out = os.Stdout + w.TimeFormat = time.RFC3339 }) } + // 包装 consoleWriter,根据 feature flag 动态控制 + writers = append(writers, &consoleWriterWrapper{w: consoleWriter}) + + // 文件输出(JSON 格式,带 logrotate) + if cfg.File != nil && cfg.File.Enabled { + fileWriter := newFileWriter(cfg.File) + writers = append(writers, fileWriter) + // 保存文件路径供 loggerdebug 使用 + logging.SetLogFilePath(cfg.File.Path) + } + + // 组合多个 writer + multiWriter := io.MultiWriter(writers...) + logger := zerolog.New(&writer{multiWriter}).Level(level).With().Timestamp().Caller().Logger() if GlobalHook != nil { hooks = append(hooks, GlobalHook) @@ -64,31 +99,69 @@ func New(cfg *logging.Config, hooks []zerolog.Hook) log.Logger { } filters := lo.Filter(cfg.Filters, func(item string, index int) bool { return strings.TrimSpace(item) != "" }) - if len(filters) > 0 { - expCode := strings.Join(filters, " && ") - log.Info().Str(logfields.Msg, "log filter expr").Msg(expCode) - exp := exprFilter(expCode) + // 设置日志过滤器 + if len(filters) > 0 || len(cfg.DisableLoggers) > 0 { + var exp *vm.Program + var expCode string + + if len(filters) > 0 { + expCode = strings.Join(filters, " && ") + log.Info().Str(logfields.Msg, "log filter expr").Msg(expCode) + exp = exprFilter(expCode) + } + log.SetEnableChecker(func(ctx context.Context, lvl log.Level, name, message string, fields log.Fields) bool { - envData := map[string]any{"level": lvl.String(), "msg": message, "name": name, "fields": fields} - output, err := expr.Run(exp, envData) - if err != nil { - log.Err(err).Str("expr", expCode).Msg("failed to run log filter expr") + // 检查 logger name 是否被禁用 + if logging.IsDisabled(name) { + return false } - return err == nil && output.(bool) + + // 检查 expr 过滤器 + if exp != nil { + envData := map[string]any{"level": lvl.String(), "msg": message, "name": name, "fields": fields} + output, err := expr.Run(exp, envData) + if err != nil { + log.Err(err).Str("expr", expCode).Msg("failed to run log filter expr") + return true // 出错时不过滤 + } + return output.(bool) + } + + return true }) } log.SetLogger(lo.ToPtr(ee.Logger())) + // 初始化扩展 loggers gl := log.GetLogger("ext") - for _, ext := range logging.List() { + for name, ext := range logging.List() { + log.Info().Str("logger", name).Msg("initializing log extension") ext(gl) } return log.GetLogger() } +// newFileWriter 创建带 logrotate 的文件 writer +func newFileWriter(cfg *logging.FileConfig) io.Writer { + // 确保目录存在 + dir := filepath.Dir(cfg.Path) + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Err(err).Str("dir", dir).Msg("failed to create log directory") + } + + return &lumberjack.Logger{ + Filename: cfg.Path, + MaxSize: cfg.MaxSize, // MB + MaxBackups: cfg.MaxBackups, // 保留的旧文件数量 + MaxAge: cfg.MaxAge, // 保留天数 + Compress: cfg.Compress, // 是否压缩 + LocalTime: true, // 使用本地时间 + } +} + type writer struct { io.Writer } @@ -96,13 +169,24 @@ type writer struct { func (w writer) Write(p []byte) (n int, err error) { n, err = w.Writer.Write(p) if err != nil { - log.Err(err).Str("raw_json", string(p)).Msg("failed to decode invalid json") - return n, err + // 使用 stderr 直接输出避免递归 + _, _ = fmt.Fprintf(os.Stderr, "[logging] write error: %v, raw: %s\n", err, string(p)) } - return n, err } +// consoleWriterWrapper 包装 console writer,根据 feature flag 动态控制输出 +type consoleWriterWrapper struct { + w io.Writer +} + +func (c *consoleWriterWrapper) Write(p []byte) (n int, err error) { + if !ConsoleLogEnabled.Value() { + return len(p), nil // 禁用时丢弃输出 + } + return c.w.Write(p) +} + func exprFilter(code string) *vm.Program { env := map[string]any{"level": "", "name": "", "msg": "", "fields": log.Fields{}} diff --git a/core/logging/logext/grpclog/log.go b/core/logging/logext/grpclog/log.go index 5ab1dece8..954aa2298 100644 --- a/core/logging/logext/grpclog/log.go +++ b/core/logging/logext/grpclog/log.go @@ -37,9 +37,9 @@ func init() { } func SetLogger(logger log.Logger) { - logger = logger.WithName("grpc").WithCallerSkip(2) + logger = logger.WithName("grpc") grpclog.SetLoggerV2(&loggerWrapper{ - log: logger, + log: logger.WithCallerSkip(2), depthLog: logger, }) } @@ -57,22 +57,26 @@ type loggerWrapper struct { printlnFilter func(args ...any) bool } +// DepthLoggerV2 实现 + func (l *loggerWrapper) InfoDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Info().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Info().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } func (l *loggerWrapper) WarningDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Warn().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Warn().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } func (l *loggerWrapper) ErrorDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Error().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Error().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } func (l *loggerWrapper) FatalDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Fatal().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Fatal().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } +// Filter 设置 + func (l *loggerWrapper) SetPrintFilter(filter func(args ...any) bool) { l.printFilter = filter } @@ -97,11 +101,12 @@ func (l *loggerWrapper) filterln(args ...any) bool { return l.printlnFilter != nil && l.printlnFilter(args...) } +// LoggerV2 实现 - Info + func (l *loggerWrapper) Info(args ...any) { if l.filter(args) { return } - l.log.Info().Msg(fmt.Sprint(args...)) } @@ -109,7 +114,6 @@ func (l *loggerWrapper) Infoln(args ...any) { if l.filterln(args) { return } - l.log.Info().Msg(fmt.Sprint(args...)) } @@ -117,15 +121,15 @@ func (l *loggerWrapper) Infof(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Info().Msg(fmt.Sprintf(format, args...)) + l.log.Info().Msgf(format, args...) } +// LoggerV2 实现 - Warning + func (l *loggerWrapper) Warning(args ...any) { if l.filter(args...) { return } - l.log.Warn().Msg(fmt.Sprint(args...)) } @@ -133,7 +137,6 @@ func (l *loggerWrapper) Warningln(args ...any) { if l.filterln(args) { return } - l.log.Warn().Msg(fmt.Sprint(args...)) } @@ -141,15 +144,15 @@ func (l *loggerWrapper) Warningf(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Warn().Msg(fmt.Sprintf(format, args...)) + l.log.Warn().Msgf(format, args...) } +// LoggerV2 实现 - Error + func (l *loggerWrapper) Error(args ...any) { if l.filter(args...) { return } - l.log.Error().Msg(fmt.Sprint(args...)) } @@ -157,7 +160,6 @@ func (l *loggerWrapper) Errorln(args ...any) { if l.filterln(args) { return } - l.log.Error().Msg(fmt.Sprint(args...)) } @@ -165,15 +167,15 @@ func (l *loggerWrapper) Errorf(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Error().Msg(fmt.Sprintf(format, args...)) + l.log.Error().Msgf(format, args...) } +// LoggerV2 实现 - Fatal + func (l *loggerWrapper) Fatal(args ...any) { if l.filter(args...) { return } - l.log.Fatal().Msg(fmt.Sprint(args...)) } @@ -181,7 +183,6 @@ func (l *loggerWrapper) Fatalln(args ...any) { if l.filterln(args) { return } - l.log.Fatal().Msg(fmt.Sprint(args...)) } @@ -189,10 +190,11 @@ func (l *loggerWrapper) Fatalf(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Fatal().Msg(fmt.Sprintf(format, args...)) + l.log.Fatal().Msgf(format, args...) } +// V 实现日志级别检查 + func (l *loggerWrapper) V(level int) bool { return _grpcToZapLevel[level] >= zerolog.GlobalLevel() } diff --git a/core/logging/loggerdebug/debug.go b/core/logging/loggerdebug/debug.go new file mode 100644 index 000000000..826300779 --- /dev/null +++ b/core/logging/loggerdebug/debug.go @@ -0,0 +1,887 @@ +package loggerdebug + +import ( + "bufio" + "encoding/json" + "fmt" + "html/template" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" + "github.com/gofiber/fiber/v3" + "github.com/pubgo/funk/v2/closer" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" + "github.com/pubgo/lava/v2/core/logging" +) + +// LogEntry 日志条目 +type LogEntry struct { + Time string `json:"time"` + Level string `json:"level"` + Caller string `json:"caller"` + Message string `json:"message"` + Logger string `json:"logger"` + Fields map[string]any `json:"-"` + Raw string `json:"raw"` +} + +// QueryRequest 查询请求 +type QueryRequest struct { + Filter string `json:"filter"` + Level string `json:"level"` + Logger string `json:"logger"` + Keyword string `json:"keyword"` + Start string `json:"start"` + End string `json:"end"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Tail bool `json:"tail"` + FileName string `json:"fileName"` +} + +// QueryResponse 查询响应 +type QueryResponse struct { + Logs []LogEntry `json:"logs"` + Total int `json:"total"` + HasMore bool `json:"hasMore"` + Files []FileInfo `json:"files"` + CurrentFile string `json:"currentFile"` +} + +// FileInfo 日志文件信息 +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime string `json:"modTime"` +} + +func init() { + debug.Route("/logs", func(router fiber.Router) { + router.Get("/", handlePage) + router.Get("/api/logs", handleQuery) + router.Get("/api/files", handleListFiles) + router.Get("/api/stats", handleStats) + }) +} + +// handlePage 渲染日志查看页面 +func handlePage(c fiber.Ctx) error { + logPath := logging.GetLogFilePath() + + html, err := ui.Render(ui.PageData{ + Title: "日志查看器", + Description: "查询和分析 JSON 日志文件", + Breadcrumb: []string{"Logs"}, + Content: template.HTML(buildPageContent(logPath)), + ExtraHead: template.HTML(buildPageScript()), + }) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + c.Set("Content-Type", "text/html; charset=utf-8") + return c.SendString(html) +} + +func buildPageScript() string { + return ` + +` +} + +func buildPageContent(logPath string) string { + return ` +
+ +
+
+ +
+ 📁 + +
+ +
+ 总计: + 错误: + 警告: +
+
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + 高级过滤 (expr 表达式) + +
+ +
+ 可用变量: + level + message + logger + caller + fields + 函数: + contains(s, sub) + startsWith(s, pre) + endsWith(s, suf) +
+
+
+
+ + +
+
+
+

日志列表

+ 显示 + 有更多记录 +
+ 加载中... +
+
+ + + + + + + + + + + + + + + + +
时间级别Logger消息Caller
+
📭
+ 暂无匹配的日志记录 +
+
+
+ + +
+
+ +
+
+

日志详情

+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+` +} + +// handleQuery 处理日志查询 +func handleQuery(c fiber.Ctx) error { + limit := 100 + if v, err := strconv.Atoi(c.Query("limit")); err == nil { + limit = v + } + if limit <= 0 { + limit = 100 + } + offset := 0 + if v, err := strconv.Atoi(c.Query("offset")); err == nil { + offset = v + } + if offset < 0 { + offset = 0 + } + tail := true + if v := strings.TrimSpace(c.Query("tail")); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + tail = b + } + } + + req := QueryRequest{ + Filter: c.Query("filter"), + Level: c.Query("level"), + Logger: c.Query("logger"), + Keyword: c.Query("keyword"), + Start: c.Query("start"), + End: c.Query("end"), + Limit: limit, + Offset: offset, + Tail: tail, + FileName: c.Query("fileName"), + } + + if req.Limit > 1000 { + req.Limit = 1000 + } + + logPath := logging.GetLogFilePath() + if logPath == "" { + return c.JSON(QueryResponse{Logs: []LogEntry{}}) + } + + filePath := logPath + if req.FileName != "" { + dir := filepath.Dir(logPath) + filePath = filepath.Join(dir, req.FileName) + } + + logs, hasMore, err := queryLogs(filePath, req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(QueryResponse{ + Logs: logs, + Total: len(logs), + HasMore: hasMore, + CurrentFile: filepath.Base(filePath), + }) +} + +// handleListFiles 列出日志文件 +func handleListFiles(c fiber.Ctx) error { + logPath := logging.GetLogFilePath() + if logPath == "" { + return c.JSON(fiber.Map{"files": []FileInfo{}}) + } + + dir := filepath.Dir(logPath) + baseName := filepath.Base(logPath) + baseNameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + + var files []FileInfo + entries, err := os.ReadDir(dir) + if err != nil { + return c.JSON(fiber.Map{"files": files}) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, baseNameNoExt) { + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, FileInfo{ + Name: name, + Size: info.Size(), + ModTime: info.ModTime().Format(time.RFC3339), + }) + } + } + + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime > files[j].ModTime + }) + + return c.JSON(fiber.Map{"files": files}) +} + +// handleStats 获取日志统计 +func handleStats(c fiber.Ctx) error { + logPath := logging.GetLogFilePath() + if logPath == "" { + return c.JSON(fiber.Map{}) + } + + fileName := c.Query("fileName") + filePath := logPath + if fileName != "" { + dir := filepath.Dir(logPath) + filePath = filepath.Join(dir, fileName) + } + + stats := countLogStats(filePath) + return c.JSON(stats) +} + +// queryLogs 查询日志 +func queryLogs(filePath string, req QueryRequest) ([]LogEntry, bool, error) { + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + return []LogEntry{}, false, nil + } + return nil, false, err + } + defer closer.SafeClose(file) + + var exprProgram *vm.Program + if req.Filter != "" { + env := map[string]any{ + "level": "", + "message": "", + "logger": "", + "caller": "", + "time": "", + "fields": map[string]any{}, + } + prog, err := expr.Compile(req.Filter, expr.Env(env), expr.AsBool()) + if err != nil { + return nil, false, fmt.Errorf("invalid filter expression: %w", err) + } + exprProgram = prog + } + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + var allLogs []LogEntry + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + entry, ok := parseLine(line) + if !ok { + continue + } + + if req.Level != "" && entry.Level != req.Level { + continue + } + + if req.Logger != "" && entry.Logger != req.Logger { + continue + } + + if req.Keyword != "" && !strings.Contains(strings.ToLower(entry.Raw), strings.ToLower(req.Keyword)) { + continue + } + + if exprProgram != nil { + env := map[string]any{ + "level": entry.Level, + "message": entry.Message, + "logger": entry.Logger, + "caller": entry.Caller, + "time": entry.Time, + "fields": entry.Fields, + } + result, err := expr.Run(exprProgram, env) + if err != nil || result != true { + continue + } + } + + allLogs = append(allLogs, entry) + } + + total := len(allLogs) + hasMore := false + var logs []LogEntry + + if req.Tail { + start := total - req.Limit - req.Offset + if start < 0 { + start = 0 + } + end := total - req.Offset + if end > total { + end = total + } + if end < 0 { + end = 0 + } + if start < end { + logs = allLogs[start:end] + } + hasMore = start > 0 + for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 { + logs[i], logs[j] = logs[j], logs[i] + } + } else { + start := req.Offset + end := req.Offset + req.Limit + if start > total { + start = total + } + if end > total { + end = total + } + logs = allLogs[start:end] + hasMore = end < total + } + + return logs, hasMore, scanner.Err() +} + +// parseLine 解析单行日志 +func parseLine(line string) (LogEntry, bool) { + var data map[string]any + if err := json.Unmarshal([]byte(line), &data); err != nil { + return LogEntry{}, false + } + + entry := LogEntry{ + Raw: line, + Fields: make(map[string]any), + } + + if v, ok := data["time"].(string); ok { + entry.Time = v + } + if v, ok := data["level"].(string); ok { + entry.Level = v + } + if v, ok := data["caller"].(string); ok { + entry.Caller = v + } + if v, ok := data["message"].(string); ok { + entry.Message = v + } + if v, ok := data["logger"].(string); ok { + entry.Logger = v + } + + for k, v := range data { + if k != "time" && k != "level" && k != "caller" && k != "message" && k != "logger" { + entry.Fields[k] = v + } + } + + return entry, true +} + +// LogStats 日志统计 +type LogStats struct { + Total int `json:"total"` + Trace int `json:"trace"` + Debug int `json:"debug"` + Info int `json:"info"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` + Loggers []string `json:"loggers"` +} + +// countLogStats 统计日志 +func countLogStats(filePath string) LogStats { + stats := LogStats{} + loggerSet := make(map[string]struct{}) + + file, err := os.Open(filePath) + if err != nil { + return stats + } + defer closer.SafeClose(file) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + stats.Total++ + + var data struct { + Level string `json:"level"` + Logger string `json:"logger"` + } + if json.Unmarshal([]byte(line), &data) == nil { + switch data.Level { + case "trace": + stats.Trace++ + case "debug": + stats.Debug++ + case "info": + stats.Info++ + case "warn", "warning": + stats.Warnings++ + case "error", "fatal", "panic": + stats.Errors++ + } + if data.Logger != "" { + loggerSet[data.Logger] = struct{}{} + } + } + } + + // 转换为排序后的列表 + for logger := range loggerSet { + stats.Loggers = append(stats.Loggers, logger) + } + sort.Strings(stats.Loggers) + + return stats +} + +// ReadLastLines 读取文件最后 n 行 +func ReadLastLines(filePath string, n int) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer closer.SafeClose(file) + + stat, err := file.Stat() + if err != nil { + return nil, err + } + + size := stat.Size() + if size == 0 { + return []string{}, nil + } + + bufSize := int64(4096) + if bufSize > size { + bufSize = size + } + + var lines []string + pos := size + var partial string + + for pos > 0 && len(lines) < n { + readSize := bufSize + if pos < bufSize { + readSize = pos + } + pos -= readSize + + buf := make([]byte, readSize) + _, err := file.ReadAt(buf, pos) + if err != nil && err != io.EOF { + return nil, err + } + + chunk := string(buf) + partial + partial = "" + + splitLines := strings.Split(chunk, "\n") + if pos > 0 { + partial = splitLines[0] + splitLines = splitLines[1:] + } + + for i := len(splitLines) - 1; i >= 0; i-- { + if splitLines[i] != "" { + lines = append([]string{splitLines[i]}, lines...) + } + if len(lines) >= n { + break + } + } + } + + if partial != "" && len(lines) < n { + lines = append([]string{partial}, lines...) + } + + return lines, nil +} diff --git a/core/pidfile/pidfile.go b/core/pidfile/pidfile.go index 592a45154..26f51a57b 100644 --- a/core/pidfile/pidfile.go +++ b/core/pidfile/pidfile.go @@ -8,10 +8,9 @@ import ( "sync" "github.com/pubgo/funk/v2/config" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" - "github.com/pubgo/funk/v2/running" - "github.com/rs/zerolog" + + "github.com/pubgo/lava/v2/core/running" ) var getPidPath = sync.OnceValue(func() string { @@ -29,9 +28,9 @@ func Get() (r result.Result[int]) { } return nil }). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("path", pidPath) - e.Str(logfields.Msg, "read pid file failed") + e.Msg("read pid file failed") }). UnwrapOrThrow(&r) if r.IsErr() { @@ -39,10 +38,10 @@ func Get() (r result.Result[int]) { } return result.Wrap(strconv.Atoi(string(p))). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("path", pidPath) e.Str("pid", string(p)) - e.Str(logfields.Msg, "convert pid to int failed") + e.Msg("convert pid to int failed") }) } @@ -53,9 +52,9 @@ func Save() (r result.Error) { pid := os.Getpid() return result.ErrOf(os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), pidPerm)). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("path", pidPath) e.Int("pid", pid) - e.Str(logfields.Msg, "write pid file failed") + e.Msg("write pid file failed") }) } diff --git a/core/registry/builder.go b/core/registry/builder.go index 60f892de1..fa0334506 100644 --- a/core/registry/builder.go +++ b/core/registry/builder.go @@ -12,9 +12,9 @@ import ( "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/log" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/lava/v2/core/lifecycle" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/core/service" "github.com/pubgo/lava/v2/internal/logutil" "github.com/pubgo/lava/v2/pkg/netutil" diff --git a/core/running/running.go b/core/running/running.go new file mode 100644 index 000000000..e224ffa20 --- /dev/null +++ b/core/running/running.go @@ -0,0 +1,159 @@ +package running + +import ( + "fmt" + "os" + rt "runtime" + "strings" + + semver "github.com/hashicorp/go-version" + "github.com/projectdiscovery/machineid" + "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/debugs" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/netutil" + "github.com/pubgo/funk/v2/pathutil" + "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/funk/v2/strutil" + "github.com/pubgo/redant" + "github.com/rs/xid" + "github.com/samber/lo" + "github.com/spf13/pflag" +) + +var ( + Env = redant.StringOf(lo.ToPtr("dev")) + Debug = redant.BoolOf(lo.ToPtr(false)) + HttpPort = redant.Int64Of(lo.ToPtr(int64(8080))) + GrpcPort = redant.Int64Of(lo.ToPtr(int64(50051))) + Project = version.Project + + InstanceID = xid.New().String() + DeviceID = InstanceID + + Version = version.Version + CommitID = version.CommitID + + Pwd = assert.Exit1(os.Getwd()) + + LocalIP = netutil.GetLocalIP() + + Hostname = strutil.FirstFnNotEmpty( + func() string { return env.Get("HOSTNAME") }, + func() string { return assert.Exit1(os.Hostname()) }, + ) + + Namespace = strutil.FirstFnNotEmpty( + func() string { return env.Get("NAMESPACE") }, + func() string { return env.Get("POD_NAMESPACE") }, + func() string { + file := "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + if pathutil.IsNotExist(file) { + return "" + } + + return strings.TrimSpace(string(assert.Exit1(os.ReadFile(file)))) + }, + ) + + Domain = version.Domain + + DebugFlag = redant.Option{ + Flag: "debug", + Description: "enable debug mode", + Value: Debug, + Default: Debug.String(), + Envs: []string{env.Key("enable_debug"), env.Key("debug")}, + Action: func(val pflag.Value) error { + env.Set("enable_debug", val.String()) + env.Set("debug", val.String()) + return debugs.Enabled.Set(val.String()) + }, + } + + EnvFlag = redant.Option{ + Flag: "runenv", + Description: "running env, dev,test,stage,prod", + Value: Env, + Default: Env.String(), + Envs: []string{env.Key("env"), env.Key("runenv")}, + Action: func(val pflag.Value) error { + env.Set("env", val.String()) + env.Set("runenv", val.String()) + return nil + }, + } + + GrpcPortFlag = redant.Option{ + Flag: "grpc-port", + Description: "service grpc port", + Value: GrpcPort, + Default: GrpcPort.String(), + Envs: []string{env.Key("server_grpc_port")}, + Action: func(val pflag.Value) error { + env.Set("server_grpc_port", val.String()) + return nil + }, + } + + HttpPortFlag = redant.Option{ + Flag: "http-port", + Description: "service http port", + Value: HttpPort, + Default: HttpPort.String(), + Envs: []string{env.Key("server_http_port")}, + Action: func(val pflag.Value) error { + env.Set("server_http_port", val.String()) + return nil + }, + } +) + +func init() { + id, err := machineid.ID() + if err == nil { + DeviceID = id + } +} + +func GetSysInfo() map[string]string { + return map[string]string{ + "main_path": version.MainPath(), + "grpc_port": GrpcPort.String(), + "http_port": HttpPort.String(), + "debug": Debug.String(), + "cur_dir": Pwd, + "local_ip": LocalIP, + "namespace": Namespace, + "instance_id": InstanceID, + "device_id": DeviceID, + "project": Project(), + "hostname": Hostname, + "build_time": version.BuildTime(), + "version": Version(), + "domain": Domain(), + "commit_id": CommitID(), + "go_root": env.Get("GOROOT"), + "go_arch": rt.GOARCH, + "go_os": rt.GOOS, + "go_version": rt.Version(), + "num_cpu": fmt.Sprintf("%v", rt.NumCPU()), + "num_goroutine": fmt.Sprintf("%v", rt.NumGoroutine()), + } +} + +func CheckVersion() { + defer recovery.Exit() + assert.If(version.Project() == "", "project is null") + assert.If(version.Version() == "", "version is null") + assert.If(version.CommitID() == "", "commitID is null") + assert.If(version.BuildTime() == "", "buildTime is null") + assert.MustFn(func() error { + _, err := semver.NewVersion(version.Version()) + if err != nil { + return fmt.Errorf("version(%s) error: %w", version.Version(), err) + } + return nil + }) +} diff --git a/core/scheduler/builder.go b/core/scheduler/builder.go index ff3deb1ef..3b6732c12 100644 --- a/core/scheduler/builder.go +++ b/core/scheduler/builder.go @@ -10,7 +10,6 @@ import ( "github.com/pubgo/funk/v2/vars" qlog "github.com/reugn/go-quartz/logger" "github.com/reugn/go-quartz/quartz" - "github.com/rs/zerolog" "github.com/pubgo/lava/v2/core/lifecycle" "github.com/pubgo/lava/v2/core/metrics" @@ -37,24 +36,24 @@ func New(m lifecycle.Lifecycle, logger log.Logger, metric metrics.Metric, config defer result.RecoveryErr(&gErr) configMap := result.Wrap(createConfig(configs)). - UnwrapOrLog(func(e *zerolog.Event) { + UnwrapOrLog(func(e result.Event) { e.Any("configs", configs) - e.Any(logfields.Msg, "failed to create config") + e.Msg("failed to create config") }) ctx, cancel := context.WithCancel(context.Background()) slogLogger := qlog.NewSlogLogger(ctx, slog.With(slog.String(logfields.Module, Name))) scheduler := result.Wrap(quartz.NewStdScheduler(quartz.WithLogger(slogLogger), quartz.WithJobMetadata())). - UnwrapOrLog(func(e *zerolog.Event) { - e.Str(logfields.Msg, "failed to create scheduler") + UnwrapOrLog(func(e result.Event) { + e.Msg("failed to create scheduler") }) jobExecutors := make(map[string]JobExecutor) for _, executor := range executors { regJobExecutor(jobExecutors, executor). - MustWithLog(func(e *zerolog.Event) { - e.Str(logfields.Msg, "failed to register job executor") + MustWithLog(func(e result.Event) { + e.Msg("failed to register job executor") }) } diff --git a/core/scheduler/config.go b/core/scheduler/config.go index 687afa3f6..5c1bee5e0 100644 --- a/core/scheduler/config.go +++ b/core/scheduler/config.go @@ -1,14 +1,11 @@ package scheduler import ( - "fmt" "time" "github.com/pubgo/funk/v2/errors" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" "github.com/reugn/go-quartz/quartz" - "github.com/rs/zerolog" "github.com/samber/lo" ) @@ -18,7 +15,8 @@ func createConfig(configs []*Config) (map[string]*JobConfig, error) { return configMap, nil } - for _, config := range configs[0].JobConfigs { + for i := range configs[0].JobConfigs { + config := &configs[0].JobConfigs[i] if config.Name == "" { return nil, errors.Errorf("schedule job name is empty") } @@ -26,6 +24,7 @@ func createConfig(configs []*Config) (map[string]*JobConfig, error) { if _, ok := configMap[config.Name]; ok { return nil, errors.Errorf("schedule job(%s) exists", config.Name) } + configMap[config.Name] = config } return configMap, nil } @@ -43,9 +42,7 @@ func defaultConfig(name string) *JobConfig { } } -func initAndMergeConfig(name string, jobConfigs ...*JobConfig) (r result.Result[*JobConfig]) { - defer result.Recovery(&r) - +func initAndMergeConfig(name string, jobConfigs ...*JobConfig) *JobConfig { cfg := defaultConfig(name) for _, jobConfig := range jobConfigs { if jobConfig == nil { @@ -76,13 +73,14 @@ func initAndMergeConfig(name string, jobConfigs ...*JobConfig) (r result.Result[ cfg.Location = jobConfig.Location } - cfg.location = result.Wrap(time.LoadLocation(lo.FromPtr(cfg.Location))). - UnwrapOrLog(func(e *zerolog.Event) { - e.Str(logfields.Msg, fmt.Sprintf("failed to parse time location:%s", lo.FromPtr(cfg.Location))) - }) + if cfg.Location != nil { + location, err := result.WrapErr(time.LoadLocation(lo.FromPtr(cfg.Location))) + err.MustWithLog(func(e result.Event) { e.Msgf("failed to parse time location:%s", lo.FromPtr(cfg.Location)) }) + cfg.location = location + } } - return r.WithValue(cfg) + return cfg } type JobConfig struct { diff --git a/core/scheduler/job.go b/core/scheduler/job.go index 9551d6dbc..ff97a853b 100644 --- a/core/scheduler/job.go +++ b/core/scheduler/job.go @@ -54,8 +54,10 @@ func (t *namedJob) Execute(ctx context.Context) (gErr error) { NextExecTime: t.task.trigger.next, } + // 检查 trigger 错误,但对于一次性任务的 ErrTriggerExpired 忽略 if t.task.trigger.err != nil { - if !errors.Is(t.task.trigger.err, quartz.ErrTriggerExpired) || t.task.spec.Once == nil { + isOnceJobExpired := errors.Is(t.task.trigger.err, quartz.ErrTriggerExpired) && t.task.spec.Once != nil + if !isOnceJobExpired { return fmt.Errorf("schedule job(%s) trigger error: %w", name, t.task.trigger.err) } } @@ -89,8 +91,9 @@ func (t *triggerImpl) NextFireTime(prev int64) (next int64, err error) { return } - t.prev = prev / 1000_000_000 - t.next = next / 1000_000_000 + // 保留毫秒精度 + t.prev = prev / 1_000_000 + t.next = next / 1_000_000 }() return t.trigger.NextFireTime(prev) diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index bdf8eea89..45c7d66f9 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -7,7 +7,6 @@ import ( "time" "github.com/pubgo/funk/v2/log" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" "github.com/reugn/go-quartz/quartz" "github.com/rs/zerolog" @@ -29,12 +28,15 @@ type Scheduler struct { ctx context.Context jobExecutors map[string]JobExecutor - mu sync.Mutex - - jobs sync.Map + mu sync.RWMutex + jobs map[string]*jobTask } func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { + if s.jobs == nil { + s.jobs = make(map[string]*jobTask) + } + task := jobTask{ spec: &spec, jobKey: parseJobKey(spec.Name), @@ -69,7 +71,7 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { } name := spec.Name - if _, ok := s.jobs.Load(name); ok { + if _, ok := s.jobs[name]; ok { return r.WithErrorf("job %s already exists", name) } @@ -89,19 +91,8 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { return r } - config := initAndMergeConfig(name, s.configMap[name], spec.Config). - Log(func(e *zerolog.Event) { - e.Str(logfields.Msg, fmt.Sprintf("failed to init schedule job(%s) config", name)) - }). - IfOK(func(config *JobConfig) { - task.spec.Config = config - }). - UnwrapOrThrow(&r) - if r.IsErr() { - return r - } - - triggerRes := getTrigger(spec, config.location). + task.spec.Config = initAndMergeConfig(name, s.configMap[name], spec.Config) + triggerRes := getTrigger(spec, task.spec.Config.location). IfErr(func(err error) { log.Err(err).Msgf("failed to get schedule job(%s) trigger", name) }). @@ -112,7 +103,7 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { return r } - jobOpt := config.ToJobDetailOptions() + jobOpt := task.spec.Config.ToJobDetailOptions() job := &namedJob{s: s, task: &task, log: s.log} jobDetail := quartz.NewJobDetailWithOptions(job, parseJobKey(name), jobOpt) @@ -120,7 +111,7 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { return r } - s.jobs.Store(name, &task) + s.jobs[name] = &task return r } @@ -131,10 +122,10 @@ func (s *Scheduler) CreateJob(spec JobSpec) (r result.Error) { } func (s *Scheduler) getJob(name string) (r result.Result[*jobTask]) { - if val, ok := s.jobs.Load(name); !ok { + if val, ok := s.jobs[name]; !ok { return r.WithErrorf("job %s not exists", name) } else { - return r.WithValue(val.(*jobTask)) + return r.WithValue(val) } } @@ -147,15 +138,7 @@ func (s *Scheduler) PatchJob(name string, config *JobConfig) (r result.Error) { return r } - initAndMergeConfig(name, job.spec.Config, config). - Log(func(e *zerolog.Event) { - e.Str(logfields.Msg, fmt.Sprintf("failed to patch schedule job(%s) config", name)) - }). - IfOK(func(config *JobConfig) { - job.spec.Config = config - }). - Throw(&r) - + job.spec.Config = initAndMergeConfig(name, job.spec.Config, config) return r } @@ -199,7 +182,7 @@ func (s *Scheduler) DeleteJob(name string) (r result.Error) { return r } - s.jobs.Delete(name) + delete(s.jobs, name) return result.ErrOf(s.scheduler.DeleteJob(job.jobKey)). IfErr(func(err error) { log.Err(err).Msgf("failed to delete schedule job(%s)", name) @@ -236,20 +219,19 @@ func (s *Scheduler) ReloadJob(name string) (r result.Error) { } func (s *Scheduler) ListJobs() []*Job { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.RLock() + defer s.mu.RUnlock() - var jobs []*Job - s.jobs.Range(func(key, value any) bool { - jobs = append(jobs, value.(*jobTask).ToJob()) - return true - }) + jobs := make([]*Job, 0, len(s.jobs)) + for _, task := range s.jobs { + jobs = append(jobs, task.ToJob()) + } return jobs } func (s *Scheduler) GetJob(name string) (r result.Result[*Job]) { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.RLock() + defer s.mu.RUnlock() job := s.getJob(name).UnwrapOrThrow(&r) if r.IsErr() { @@ -264,11 +246,15 @@ func (s *Scheduler) String() string { } func (s *Scheduler) Serve(ctx context.Context) error { + // 每次 Serve 调用时重新创建内部 context,支持服务重启 + s.ctx, s.cancel = context.WithCancel(ctx) defer s.stop() s.start() - s.scheduler.Wait(ctx) - return nil + s.scheduler.Wait(s.ctx) + + // 返回 context 的错误,这样 supervisor 能正确判断是正常停止还是需要重启 + return ctx.Err() } func (s *Scheduler) stop() { diff --git a/core/scheduler/schedulerdebug/debug.go b/core/scheduler/schedulerdebug/debug.go index 65b7674c1..2d7d226a0 100644 --- a/core/scheduler/schedulerdebug/debug.go +++ b/core/scheduler/schedulerdebug/debug.go @@ -1,142 +1,674 @@ package schedulerdebug import ( - "time" + "encoding/json" + "fmt" + "html/template" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" "github.com/pubgo/lava/v2/core/scheduler" ) -func Init(scheduler scheduler.JobManager) { +func Init(manager scheduler.JobManager) { debug.Route("/scheduler", func(router fiber.Router) { - router.Get("list", func(ctx *fiber.Ctx) error { - ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - return Page(time.Now(), scheduler.ListJobs()).Render(ctx) + // 任务列表页面 + router.Get("/", func(ctx fiber.Ctx) error { + return renderPage(ctx, manager) }) - router.Get("/get", func(ctx *fiber.Ctx) error { - dd := ` - - - - - 基础菜单 - Layui - - - - - - -
-
    -
  • - -
  • -
  • - -
  • -
  • -
  • -
    - menu group -
    -
      -
    • -
      menu item 3-1
      -
    • -
    • -
      menu group 2
      -
        -
      • -
        menu item 3-2-1
        -
      • -
      • menu item 3-2-2
      • -
      -
    • -
    • menu item 3-3
    • -
    -
  • -
  • -
  • menu item 4 1
  • -
  • menu item 5
  • -
  • menu item 6
  • -
  • -
    - menu item 7 Children - -
    -
    -
      -
    • -
      - menu item 7-1 - -
      -
      -
        -
      • menu item 7-2-1
      • -
      • menu item 7-2-2
      • -
      • menu item 7-2-3
      • -
      • menu item 7-2-4
      • -
      -
      -
    • -
    • menu item 7-2
    • -
    • menu item 7-3
    • -
    -
    -
  • -
  • menu item 8
  • -
  • -
  • -
    menu group 9
    -
      -
    • menu item 9-1
    • -
    • -
      - menu item 9-2 - -
      -
      -
        -
      • menu item 9-2-1
      • -
      • menu item 9-2-2
      • -
      • menu item 9-2-3
      • -
      -
      -
    • -
    • menu item 9-31
    • -
    -
  • -
  • -
  • menu item 10
  • -
-
- - - - -` - ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - _, err := ctx.WriteString(dd) - return err + // API: 获取所有任务 + router.Get("/api/jobs", func(ctx fiber.Ctx) error { + return ctx.JSON(manager.ListJobs()) + }) + + // API: 获取指定任务详情 + router.Get("/api/jobs/:name", func(ctx fiber.Ctx) error { + name := ctx.Params("name") + job := manager.GetJob(name) + if job.IsErr() { + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": job.GetErr().Error(), + }) + } + return ctx.JSON(job.Unwrap()) + }) + + // API: 暂停任务 + router.Post("/api/jobs/:name/pause", func(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.PauseJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "paused", "name": name}) + }) + + // API: 恢复任务 + router.Post("/api/jobs/:name/resume", func(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.ResumeJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "resumed", "name": name}) + }) + + // API: 删除任务 + router.Delete("/api/jobs/:name", func(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.DeleteJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "deleted", "name": name}) + }) + + // API: 重载任务 + router.Post("/api/jobs/:name/reload", func(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.ReloadJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "reloaded", "name": name}) }) }) } + +func renderPage(ctx fiber.Ctx, manager scheduler.JobManager) error { + jobs := manager.ListJobs() + + // 统计信息 + var runningCount, stoppedCount int + for _, job := range jobs { + if job.Status == scheduler.StatusRunning { + runningCount++ + } else { + stoppedCount++ + } + } + + // 构建初始数据 + jobsJSON, _ := json.Marshal(jobs) + + content := buildContent(len(jobs), runningCount, stoppedCount) + + html, err := ui.Render(ui.PageData{ + Title: "Scheduler", + Description: "任务调度管理", + Breadcrumb: []string{"Scheduler"}, + Content: content, + ExtraHead: extraHead(string(jobsJSON)), + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + + ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) + return ctx.SendString(html) +} + +func extraHead(jobsJSON string) template.HTML { + return template.HTML(fmt.Sprintf(` + +`, jobsJSON)) +} + +func buildContent(total, running, stopped int) template.HTML { + return template.HTML(fmt.Sprintf(` +
+ +
+
+
总任务数
+
%d
+
+
+
运行中
+
%d
+
+
+
已停止
+
%d
+
+
+
自动刷新
+
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
状态任务名称类型执行次数上次执行下次执行最近结果操作
+
+
+ + +
+
+
+
+ +
+
+
+ + + +
+
+

+

+
+
+ +
+ + +
+ +
+
+
状态
+
+
+ +
+
+
+
执行次数
+
+
+
+
上次执行
+
+
+
+
下次执行
+
+
+
+ + +
+

+ + + + 调度配置 +

+
+ + + + + + + +
+
+ + +
+

+ + + + 最近执行结果 +

+ + + + +
+ + +
+ + + + + + 查看原始 JSON + + + + + +

+                    
+
+ + +
+
+ + + +
+ +
+
+
+
+ + +
+
+ +
+
+
`, total, running, stopped)) +} diff --git a/core/scheduler/schedulerdebug/html.go b/core/scheduler/schedulerdebug/html.go index ffb4f5177..4f622e1c2 100644 --- a/core/scheduler/schedulerdebug/html.go +++ b/core/scheduler/schedulerdebug/html.go @@ -43,8 +43,8 @@ func ListSchedulers(schedulers []*scheduler.Job) Node { return Tr( Th(Text(s.Spec.Name)), Th(Text(string(s.Status))), - Th(Textf("%v", s.PreExecTime)), - Th(Textf("%v", s.ExecTime)), + Th(Text(formatTime(s.PreExecTime))), + Th(Text(formatTime(s.ExecTime))), Th(NodeFn(func() Node { if s.Error != nil { return Text(s.Error.Error()) @@ -97,7 +97,17 @@ func ListSchedulers(schedulers []*scheduler.Job) Node { ) } -const timeOnly = "15:04:05" +const ( + timeOnly = "15:04:05" + timeFormat = "2006-01-02 15:04:05" +) + +func formatTime(ms int64) string { + if ms == 0 { + return "-" + } + return time.UnixMilli(ms).Format(timeFormat) +} func Page(now time.Time, schedulers []*scheduler.Job) Node { return Doctype( diff --git a/core/supervisor/_.go b/core/supervisor/_.go deleted file mode 100644 index 426e0e9cc..000000000 --- a/core/supervisor/_.go +++ /dev/null @@ -1,4 +0,0 @@ -package supervisor - -// "github.com/kardianos/service" -// github.com/thejerf/suture diff --git a/core/supervisor/aaa.go b/core/supervisor/aaa.go deleted file mode 100644 index 760de73c9..000000000 --- a/core/supervisor/aaa.go +++ /dev/null @@ -1,32 +0,0 @@ -package supervisor - -import ( - "context" - "time" - - "github.com/thejerf/suture/v4" -) - -type Supervisor = suture.Supervisor - -type Metric struct { - Name string - Error string - Restart uint32 - StartTime time.Time - OnlineDuration time.Duration -} - -type Service interface { - Name() string - Error() error - String() string - Serve(ctx context.Context) error - Metric() *Metric -} - -type serviceFn func(ctx context.Context) error - -func (fn serviceFn) Serve(ctx context.Context) error { - return fn(ctx) -} diff --git a/core/supervisor/debug/debug.go b/core/supervisor/debug/debug.go new file mode 100644 index 000000000..c42f6acdf --- /dev/null +++ b/core/supervisor/debug/debug.go @@ -0,0 +1,133 @@ +package debug + +import ( + _ "embed" + + "github.com/gofiber/fiber/v3" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/supervisor" +) + +func Register(mgr *supervisor.Manager) { + h := &handler{mgr: mgr} + + debug.Route("/supervisor", func(router fiber.Router) { + // 主页面 - UI 界面 + router.Get("/", h.handleDebugPage) + + // API 端点 + router.Get("/api/services", h.handleAPIServices) + router.Get("/api/service/:name", h.handleAPIServiceDetail) + router.Post("/api/service/:name/restart", h.handleAPIRestartService) + router.Post("/api/service/:name/stop", h.handleAPIStopService) + router.Post("/api/service/:name/start", h.handleAPIStartService) + router.Post("/api/service/:name/reset", h.handleAPIResetService) + router.Post("/api/services/restart", h.handleAPIRestartAll) + + // 兼容旧端点 + router.Get("services", h.handleAPIServices) + }) +} + +type handler struct { + mgr *supervisor.Manager +} + +func (h *handler) handleAPIServices(ctx fiber.Ctx) error { + services := h.mgr.GetServicesInfo() + return ctx.JSON(services) +} + +func (h *handler) handleAPIServiceDetail(ctx fiber.Ctx) error { + name := ctx.Params("name") + info, err := h.mgr.GetServiceInfo(name) + if err != nil { + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "service not found", + "name": name, + }) + } + return ctx.JSON(info) +} + +func (h *handler) handleAPIRestartService(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := h.mgr.RestartService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service restarted", + "name": name, + }) +} + +func (h *handler) handleAPIStopService(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := h.mgr.StopService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service stopped", + "name": name, + }) +} + +func (h *handler) handleAPIStartService(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := h.mgr.StartService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service started", + "name": name, + }) +} + +func (h *handler) handleAPIResetService(ctx fiber.Ctx) error { + name := ctx.Params("name") + if err := h.mgr.ResetService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service reset", + "name": name, + }) +} + +func (h *handler) handleAPIRestartAll(ctx fiber.Ctx) error { + if err := h.mgr.RestartServices(); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "all services restarted", + }) +} + +func (h *handler) handleDebugPage(ctx fiber.Ctx) error { + html := supervisorDebugPageHTML + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) +} + +//go:embed index.html +var supervisorDebugPageHTML string diff --git a/core/supervisor/debug/index.html b/core/supervisor/debug/index.html new file mode 100644 index 000000000..be7fa204f --- /dev/null +++ b/core/supervisor/debug/index.html @@ -0,0 +1,375 @@ + + + + + + Supervisor - Debug Console + + + + + + + +
+ +
+
+
服务总数
+
0
+
+
+
运行中
+
0
+
+
+
错误状态
+
0
+
+
+
总启动次数
+
0
+
+
+
总错误次数
+
0
+
+
+ + +
+
+

服务列表

+ 自动刷新: 每 +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + +
服务名状态启动次数错误次数重启次数运行时长重启延迟最后错误操作
+
+
+ + +
+
+
+

服务详情:

+ +
+
+

+                
+
+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/core/supervisor/errs.go b/core/supervisor/errs.go index 86e056283..f5dec2eb7 100644 --- a/core/supervisor/errs.go +++ b/core/supervisor/errs.go @@ -1,30 +1,44 @@ package supervisor import ( - "github.com/pubgo/funk/v2/errors" - "github.com/thejerf/suture/v4" + "errors" ) +// 预定义错误 +var ( + // ErrServiceNotFound 服务未找到 + ErrServiceNotFound = errors.New("service not found") + // ErrServiceAlreadyExists 服务已存在 + ErrServiceAlreadyExists = errors.New("service already exists") + // ErrServiceStopped 服务已停止 + ErrServiceStopped = errors.New("service is stopped") + // ErrServiceRunning 服务正在运行 + ErrServiceRunning = errors.New("service is running") + // ErrDoNotRestart 不要重启服务 + ErrDoNotRestart = errors.New("do not restart") + // ErrTerminateSupervisor 终止 supervisor + ErrTerminateSupervisor = errors.New("terminate supervisor") +) + +// FatalErr 致命错误,将导致 supervisor 终止 type FatalErr struct { Err error Status ExitStatus } -// AsFatalErr wraps the given error creating a FatalErr. If the given error -// already is of type FatalErr, it is not wrapped again. -func AsFatalErr(err error, status ExitStatus) (gErr *FatalErr) { - if errors.As(err, &gErr) { - return gErr - } - - return &FatalErr{ - Err: err, - Status: status, +// AsFatalErr 将错误包装为 FatalErr +func AsFatalErr(err error, status ExitStatus) *FatalErr { + var fErr *FatalErr + if errors.As(err, &fErr) { + return fErr } + return &FatalErr{Err: err, Status: status} } +// IsFatal 判断是否为致命错误 func IsFatal(err error) bool { - return errors.As(err, &FatalErr{}) + var fErr *FatalErr + return errors.As(err, &fErr) } func (e *FatalErr) Error() string { @@ -36,18 +50,27 @@ func (e *FatalErr) Unwrap() error { } func (*FatalErr) Is(target error) bool { - return target == suture.ErrTerminateSupervisorTree + return target == ErrTerminateSupervisor } -// NoRestartErr wraps the given error err (which may be nil) to make sure that -// `errors.Is(err, suture.ErrDoNotRestart) == true`. +// NoRestartErr 包装错误,使其不会触发自动重启 func NoRestartErr(err error) error { if err == nil { - return suture.ErrDoNotRestart + return ErrDoNotRestart } return &noRestartErr{err} } +// IsNoRestartErr 判断是否是不需要重启的错误 +func IsNoRestartErr(err error) bool { + return errors.Is(err, ErrDoNotRestart) +} + +// IsFatalErr 判断是否是致命错误 +func IsFatalErr(err error) bool { + return IsFatal(err) || errors.Is(err, ErrTerminateSupervisor) +} + type noRestartErr struct { err error } @@ -61,9 +84,10 @@ func (e *noRestartErr) Unwrap() error { } func (*noRestartErr) Is(target error) bool { - return target == suture.ErrDoNotRestart + return target == ErrDoNotRestart } +// ExitStatus 退出状态码 type ExitStatus int const ( diff --git a/core/supervisor/manager.go b/core/supervisor/manager.go index 577606195..90881b734 100644 --- a/core/supervisor/manager.go +++ b/core/supervisor/manager.go @@ -3,27 +3,35 @@ package supervisor import ( "context" "fmt" + "sync" + "time" - "github.com/gofiber/fiber/v2" "github.com/pubgo/funk/v2/assert" - "github.com/pubgo/funk/v2/async" - "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/result" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/funk/v2/stack" - "github.com/thejerf/suture/v4" - "github.com/pubgo/lava/v2/core/debug" "github.com/pubgo/lava/v2/core/lifecycle" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/internal/logutil" - "github.com/pubgo/lava/v2/pkg/netutil" ) -type serviceWrapper struct { - token suture.ServiceToken +// serviceRunner 管理单个服务的运行 +type serviceRunner struct { service Service + config ServiceConfig + cancel context.CancelFunc + stopped bool // 是否被手动停止 + failed bool // 是否已失败(达到重启上限) + done chan struct{} + + // 重启状态跟踪 + restartCount int // 总重启次数 + consecFailures int // 连续失败次数 + windowRestarts int // 窗口期内重启次数 + windowStart time.Time // 窗口开始时间 + currentDelay time.Duration // 当前重启延迟 + lastServiceStart time.Time // 上次服务启动时间 } func Default(lc lifecycle.Getter) *Manager { @@ -32,114 +40,309 @@ func Default(lc lifecycle.Getter) *Manager { func NewManager(name string, lc lifecycle.Getter) *Manager { m := &Manager{ - lc: lc, - supervisor: suture.New(name, SpecWithInfoLogger()), - services: make(map[string]*serviceWrapper), - logger: log.GetLogger(name), + name: name, + lc: lc, + services: make(map[string]*serviceRunner), + logger: log.GetLogger(name), } - return m.init() + return m } type Manager struct { - lc lifecycle.Getter - logger log.Logger - supervisor *Supervisor - services map[string]*serviceWrapper -} - -func (m *Manager) init() *Manager { - debug.Route("/supervisor", func(router fiber.Router) { - router.Get("services", func(ctx *fiber.Ctx) error { - var services []*Metric - for _, srv := range m.services { - services = append(services, srv.service.Metric()) - } - return ctx.JSON(services) + name string + lc lifecycle.Getter + logger log.Logger + mu sync.RWMutex + services map[string]*serviceRunner + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// GetServicesInfo returns the status info of all services +func (m *Manager) GetServicesInfo() []*ServiceInfo { + m.mu.RLock() + defer m.mu.RUnlock() + + services := make([]*ServiceInfo, 0, len(m.services)) + for _, srv := range m.services { + metric := srv.service.Metric() + metric.Status = m.statusFromRunner(srv) + services = append(services, &ServiceInfo{ + Metric: metric, + Stopped: srv.stopped, + Failed: srv.failed, + RestartCount: srv.restartCount, + ConsecFailures: srv.consecFailures, + WindowRestarts: srv.windowRestarts, + CurrentDelay: srv.currentDelay, + CurrentDelayStr: srv.currentDelay.String(), + WindowStart: srv.windowStart, + LastServiceStart: srv.lastServiceStart, }) - }) + } + return services +} - return m +// GetServiceInfo returns the status info of a specific service +func (m *Manager) GetServiceInfo(name string) (*ServiceInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + srv, ok := m.services[name] + if !ok { + return nil, fmt.Errorf("service not found: %s", name) + } + + metric := srv.service.Metric() + metric.Status = m.statusFromRunner(srv) + info := &ServiceInfo{ + Metric: metric, + Stopped: srv.stopped, + Failed: srv.failed, + RestartCount: srv.restartCount, + ConsecFailures: srv.consecFailures, + WindowRestarts: srv.windowRestarts, + CurrentDelay: srv.currentDelay, + CurrentDelayStr: srv.currentDelay.String(), + WindowStart: srv.windowStart, + LastServiceStart: srv.lastServiceStart, + } + return info, nil +} + +func (m *Manager) statusFromRunner(runner *serviceRunner) ServiceStatus { + if runner.failed { + return StatusFailed + } + if runner.stopped { + return StatusStopped + } + if runner.consecFailures > 0 { + return StatusCrashing + } + if runner.cancel != nil { + return StatusRunning + } + return StatusIdle } func (m *Manager) Has(name string) bool { + m.mu.RLock() _, ok := m.services[name] + m.mu.RUnlock() return ok } func (m *Manager) OnClose(fn func()) { - m.supervisor.Add(serviceFn(func(ctx context.Context) error { - <-ctx.Done() - fn() - return nil - })) + _ = m.Add(&onCloseService{fn: fn}) +} + +type onCloseService struct { + fn func() +} + +func (s *onCloseService) Name() string { return "on-close-" + fmt.Sprintf("%p", s.fn) } +func (s *onCloseService) Error() error { return nil } +func (s *onCloseService) String() string { return s.Name() } +func (s *onCloseService) Metric() *Metric { return &Metric{Name: s.Name()} } +func (s *onCloseService) Serve(ctx context.Context) error { + <-ctx.Done() + s.fn() + return NoRestartErr(nil) +} + +func (m *Manager) Add(srv Service, opts ...Option) error { + config := DefaultServiceConfig() + for _, opt := range opts { + opt(&config) + } + return m.AddWithConfig(srv, config) } -func (m *Manager) Add(srv Service) error { +// AddWithConfig 添加服务并指定配置 +func (m *Manager) AddWithConfig(srv Service, config ServiceConfig) error { name := srv.Name() + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.services[name]; ok { - return errors.Errorf("service already exists, name=%s", name) + return fmt.Errorf("service already exists, name=%s", name) } m.logger.Info().Str("name", name).Msg("add service to supervisor") - m.services[name] = &serviceWrapper{service: srv, token: m.supervisor.Add(srv)} + runner := &serviceRunner{ + service: srv, + config: config, + stopped: !config.AutoStart, + } + m.services[name] = runner + + // 如果 manager 已经启动,立即启动这个服务 + if m.ctx != nil && !runner.stopped { + m.startRunner(runner) + } + return nil } func (m *Manager) Delete(name string) error { + m.mu.Lock() srv := m.services[name] if srv == nil { + m.mu.Unlock() m.logger.Warn().Str("name", name).Msg("service not found, cannot delete") - return nil + return fmt.Errorf("service not found, name=%s", name) + } + + // 停止服务 + cancel := srv.cancel + done := srv.done + delete(m.services, name) + m.mu.Unlock() + + if cancel != nil { + cancel() + if done != nil { + <-done + } } - defer func() { delete(m.services, name) }() m.logger.Info().Str("name", name).Msg("delete service from supervisor") - return errors.Wrapf(m.supervisor.Remove(srv.token), "failed to remove service, name=%s", name) + return nil } -func (m *Manager) RemoveServices() (gErr error) { +func (m *Manager) RemoveServices() error { + m.mu.Lock() + defer m.mu.Unlock() + for name, srv := range m.services { - if result.ThrowErr(&gErr, m.supervisor.Remove(srv.token)) { - return errors.Wrapf(gErr, "failed to remove service, name=%s", name) + if srv.cancel != nil { + srv.cancel() } m.logger.Info().Str("name", name).Msg("removing service from supervisor") } - m.services = make(map[string]*serviceWrapper) + m.services = make(map[string]*serviceRunner) return nil } -func (m *Manager) RestartServices() (gErr error) { - for name, srv := range m.services { - if result.ThrowErr(&gErr, m.supervisor.Remove(srv.token)) { - return errors.Wrapf(gErr, "failed to remove service, name=%s", name) +func (m *Manager) RestartServices() error { + m.mu.RLock() + names := make([]string, 0, len(m.services)) + for name := range m.services { + names = append(names, name) + } + m.mu.RUnlock() + + for _, name := range names { + // restart each service individually to avoid holding lock for too long + if err := m.RestartService(name); err != nil { + m.logger.Warn().Err(err).Str("name", name).Msg("failed to restart service") } + } - m.services[name] = &serviceWrapper{service: srv.service, token: m.supervisor.Add(srv.service)} - m.logger.Info().Str("name", name).Msg("restarting service in supervisor") + return nil +} + +func (m *Manager) RestartService(name string) error { + m.logger.Info().Str("name", name).Msg("restarting service in supervisor") + + if err := m.StopService(name); err != nil { + return err + } + + return m.StartService(name) +} + +// StopService 暂停服务,但保留在 services map 中 +func (m *Manager) StopService(name string) error { + m.mu.Lock() + srv := m.services[name] + if srv == nil { + m.mu.Unlock() + m.logger.Warn().Str("name", name).Msg("service not found, cannot stop") + return fmt.Errorf("service not found, name=%s", name) } + if srv.stopped { + m.mu.Unlock() + m.logger.Warn().Str("name", name).Msg("service already stopped") + return nil + } + + cancel := srv.cancel + done := srv.done + srv.stopped = true + m.mu.Unlock() + + if cancel != nil { + cancel() + if done != nil { + <-done + } + } + + m.logger.Info().Str("name", name).Msg("stopped service in supervisor") return nil } -func (m *Manager) RestartService(name string) (gErr error) { +// StartService 启动已暂停的服务 +func (m *Manager) StartService(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + srv := m.services[name] if srv == nil { - m.logger.Warn().Str("name", name).Msg("service not found, cannot restart") + m.logger.Warn().Str("name", name).Msg("service not found, cannot start") + return fmt.Errorf("service not found, name=%s", name) + } + + if !srv.stopped && !srv.failed { + m.logger.Warn().Str("name", name).Msg("service already running") return nil } - if result.ThrowErr(&gErr, m.supervisor.Remove(srv.token)) { - return errors.Wrapf(gErr, "failed to remove service, name=%s", name) + // 重置状态 + srv.stopped = false + srv.failed = false + srv.consecFailures = 0 + srv.windowRestarts = 0 + srv.windowStart = time.Now() + srv.currentDelay = srv.config.RestartDelay + + m.startRunner(srv) + m.logger.Info().Str("name", name).Msg("started service in supervisor") + return nil +} + +// ResetService 重置失败的服务,清除所有重启计数 +func (m *Manager) ResetService(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + srv := m.services[name] + if srv == nil { + return fmt.Errorf("service not found, name=%s", name) } - m.services[name] = &serviceWrapper{service: srv.service, token: m.supervisor.Add(srv.service)} - m.logger.Info().Str("name", name).Msg("restarting service in supervisor") + // 重置所有重启相关状态 + srv.failed = false + srv.restartCount = 0 + srv.consecFailures = 0 + srv.windowRestarts = 0 + srv.windowStart = time.Now() + srv.currentDelay = srv.config.RestartDelay + m.logger.Info().Str("name", name).Msg("reset service restart counters") return nil } func (m *Manager) Services() []Service { + m.mu.RLock() + defer m.mu.RUnlock() + services := make([]Service, 0, len(m.services)) for _, srv := range m.services { services = append(services, srv.service) @@ -147,8 +350,163 @@ func (m *Manager) Services() []Service { return services } +// startRunner 启动一个服务 runner +func (m *Manager) startRunner(runner *serviceRunner) { + if m.ctx == nil { + return + } + + ctx, cancel := context.WithCancel(m.ctx) + runner.cancel = cancel + runner.done = make(chan struct{}) + + m.wg.Add(1) + go func() { + defer m.wg.Done() + defer close(runner.done) + m.runService(ctx, runner) + }() +} + +// runService 运行服务的主循环,包含自动重启逻辑 +func (m *Manager) runService(ctx context.Context, runner *serviceRunner) { + srv := runner.service + name := srv.Name() + config := runner.config + + // 初始化重启状态 + if runner.currentDelay == 0 { + runner.currentDelay = config.RestartDelay + } + if runner.windowStart.IsZero() { + runner.windowStart = time.Now() + } + + for { + // 先检查 context 是否已取消 + select { + case <-ctx.Done(): + return + default: + } + + // 记录服务启动时间 + runner.lastServiceStart = time.Now() + + err := srv.Serve(ctx) + + // Serve 返回后立即检查 context + // 如果 context 已取消,无论错误是什么都应该退出 + select { + case <-ctx.Done(): + return + default: + } + + // 计算运行时长 + runDuration := time.Since(runner.lastServiceStart) + + // 处理错误 + if err != nil { + // context 相关错误视为正常停止,不重启 + if err == context.Canceled || err == context.DeadlineExceeded { + return + } + + // 检查是否是不重启的错误 + if IsNoRestartErr(err) || IsFatalErr(err) { + runner.failed = true + m.logger.Error().Err(err).Str("name", name).Msg("service exited with fatal error, not restarting") + return + } + + // 根据重启策略决定是否重启 + if config.RestartPolicy == RestartNever { + runner.failed = true + m.logger.Error().Err(err).Str("name", name).Msg("service exited with error, restart policy is Never") + return + } + + runner.consecFailures++ + m.logger.Warn().Err(err).Str("name", name). + Int("consec_failures", runner.consecFailures). + Msg("service exited with error") + } else { + // 正常退出 + if config.RestartPolicy == RestartOnFailure || config.RestartPolicy == RestartNever { + m.logger.Info().Str("name", name).Msg("service exited normally, not restarting per policy") + return + } + + // RestartAlways 策略下正常退出也会重启 + // 如果运行了足够长的时间,重置连续失败计数 + if runDuration > config.RestartWindow { + runner.consecFailures = 0 + runner.currentDelay = config.RestartDelay + } + + m.logger.Info().Str("name", name).Msg("service exited normally, will restart") + } + + // 更新窗口内重启计数 + now := time.Now() + if now.Sub(runner.windowStart) > config.RestartWindow { + // 窗口已过期,重置 + runner.windowStart = now + runner.windowRestarts = 0 + runner.currentDelay = config.RestartDelay // 重置延迟 + } + runner.windowRestarts++ + runner.restartCount++ + + // 检查是否超过最大重启次数 + if config.MaxRestarts > 0 && runner.restartCount >= config.MaxRestarts { + runner.failed = true + m.logger.Error().Str("name", name). + Int("restarts", runner.restartCount). + Int("max_restarts", config.MaxRestarts). + Msg("service exceeded max restart limit, marking as failed") + return + } + + // 检查窗口期内重启次数 + if config.MaxRestartsInWindow > 0 && runner.windowRestarts > config.MaxRestartsInWindow { + runner.failed = true + m.logger.Error().Str("name", name). + Int("window_restarts", runner.windowRestarts). + Int("max_in_window", config.MaxRestartsInWindow). + Dur("window", config.RestartWindow). + Msg("service restart rate too high, marking as failed") + return + } + + // 计算并应用退避延迟 + delay := runner.currentDelay + m.logger.Info().Str("name", name). + Dur("delay", delay). + Int("window_restarts", runner.windowRestarts). + Int("total_restarts", runner.restartCount). + Msg("waiting before restart") + + select { + case <-ctx.Done(): + return + case <-time.After(delay): + // 应用指数退避 + runner.currentDelay = time.Duration(float64(runner.currentDelay) * config.BackoffMultiplier) + if runner.currentDelay > config.MaxRestartDelay { + runner.currentDelay = config.MaxRestartDelay + } + } + } +} + func (m *Manager) start(ctx context.Context) error { defer recovery.Exit() + + // 保存 context + m.ctx, m.cancel = context.WithCancel(ctx) + logutil.OkOrFailed(m.logger, "start lifecycle before service", func() error { defer recovery.Exit() for _, run := range m.lc.GetBeforeStarts() { @@ -158,14 +516,14 @@ func (m *Manager) start(ctx context.Context) error { return nil }) - async.GoDelay(func() error { - err := m.supervisor.Serve(ctx) - if netutil.IsErrServerClosed(err) { - return nil + // 启动所有服务 + m.mu.RLock() + for _, runner := range m.services { + if !runner.stopped { + m.startRunner(runner) } - assert.Exit(err) - return nil - }) + } + m.mu.RUnlock() logutil.OkOrFailed(m.logger, "start lifecycle after service", func() error { defer recovery.Exit() @@ -191,12 +549,23 @@ func (m *Manager) stop(ctx context.Context) error { return nil }) - unstoppedServices, _ := m.supervisor.UnstoppedServiceReport() - if len(unstoppedServices) > 0 { - for _, service := range unstoppedServices { - m.logger.Error().Any("service", service).Msgf("service:%s is still running", service.Name) - } - return errors.New("services are still running") + // 取消所有服务 + if m.cancel != nil { + m.cancel() + } + + // 等待所有服务停止,带超时 + done := make(chan struct{}) + go func() { + m.wg.Wait() + close(done) + }() + + select { + case <-done: + m.logger.Info().Msg("all services stopped") + case <-time.After(30 * time.Second): + m.logger.Warn().Msg("timeout waiting for services to stop") } logutil.OkOrFailed(m.logger, "stop lifecycle after service", func() error { @@ -223,15 +592,27 @@ func (m *Manager) Run(ctx context.Context) error { } func (m *Manager) Serve(ctx context.Context) error { - err := m.supervisor.Serve(ctx) + err := m.start(ctx) + if err != nil { + return err + } - if netutil.IsErrServerClosed(err) { - return nil + // 等待 context 取消 + <-ctx.Done() + + // 停止所有服务 + if m.cancel != nil { + m.cancel() } + m.wg.Wait() - return err + return nil } func (m *Manager) ServeBackground(ctx context.Context) <-chan error { - return m.supervisor.ServeBackground(ctx) + errCh := make(chan error, 1) + go func() { + errCh <- m.Serve(ctx) + }() + return errCh } diff --git a/core/supervisor/service.go b/core/supervisor/service.go index 2d0e1debf..ed9faee07 100644 --- a/core/supervisor/service.go +++ b/core/supervisor/service.go @@ -12,14 +12,23 @@ import ( ) type serviceMetric struct { - Error atomic.String - Restart atomic.Uint32 - StartTime atomic.Time - OnlineDuration atomic.Duration + Status atomic.String // 当前状态 + StartCount atomic.Uint32 // 启动次数 + ErrorCount atomic.Uint32 // 错误次数 + SuccessCount atomic.Uint32 // 成功退出次数 + LastError atomic.String // 最后一次错误信息 + LastErrorTime atomic.Time // 最后一次错误时间 + LastStartTime atomic.Time // 最后一次启动时间 + LastStopTime atomic.Time // 最后一次停止时间 + TotalUptime atomic.Duration // 总运行时长 + CreatedAt atomic.Time // 服务创建时间 } func NewService(name string, fn func(ctx context.Context) error) Service { - return &serviceImpl{name: name, fn: fn, metric: &serviceMetric{}} + m := &serviceMetric{} + m.Status.Store(string(StatusIdle)) + m.CreatedAt.Store(time.Now()) + return &serviceImpl{name: name, fn: fn, metric: m} } var _ Service = &serviceImpl{} @@ -33,13 +42,38 @@ type serviceImpl struct { } func (s *serviceImpl) Metric() *Metric { - metric := s.metric + m := s.metric + status := ServiceStatus(m.Status.Load()) + startCount := m.StartCount.Load() + lastStartTime := m.LastStartTime.Load() + totalUptime := m.TotalUptime.Load() + + // 计算当前运行时长 + var currentUptime time.Duration + if status == StatusRunning && !lastStartTime.IsZero() { + currentUptime = time.Since(lastStartTime) + } + + // 计算平均运行时长 + var averageUptime time.Duration + if startCount > 0 { + averageUptime = (totalUptime + currentUptime) / time.Duration(startCount) + } + return &Metric{ - Name: s.name, - Error: metric.Error.Load(), - Restart: metric.Restart.Load(), - StartTime: metric.StartTime.Load(), - OnlineDuration: time.Since(metric.StartTime.Load()), + Name: s.name, + Status: status, + StartCount: startCount, + ErrorCount: m.ErrorCount.Load(), + SuccessCount: m.SuccessCount.Load(), + LastError: m.LastError.Load(), + LastErrorTime: m.LastErrorTime.Load(), + LastStartTime: lastStartTime, + LastStopTime: m.LastStopTime.Load(), + CurrentUptime: currentUptime, + TotalUptime: totalUptime + currentUptime, + AverageUptime: averageUptime, + CreatedAt: m.CreatedAt.Load(), } } @@ -56,14 +90,33 @@ func (s *serviceImpl) String() string { } func (s *serviceImpl) Serve(ctx context.Context) (gErr error) { - s.metric.StartTime.Store(time.Now()) + startTime := time.Now() + s.metric.LastStartTime.Store(startTime) + s.metric.Status.Store(string(StatusRunning)) + s.metric.StartCount.Add(1) + defer func() { - if gErr != nil { - s.err = gErr - } + stopTime := time.Now() + runDuration := stopTime.Sub(startTime) + + // 累加总运行时长 + s.metric.TotalUptime.Add(runDuration) + s.metric.LastStopTime.Store(stopTime) - if s.err != nil { - s.metric.Error.Store(s.err.Error()) + // context 取消或超时视为正常停止 + isNormalStop := gErr == nil || + errors.Is(gErr, context.Canceled) || + errors.Is(gErr, context.DeadlineExceeded) + + if isNormalStop { + s.metric.Status.Store(string(StatusStopped)) + s.metric.SuccessCount.Add(1) + } else { + s.err = gErr + s.metric.Status.Store(string(StatusError)) + s.metric.ErrorCount.Add(1) + s.metric.LastError.Store(gErr.Error()) + s.metric.LastErrorTime.Store(stopTime) } log.Info(ctx). @@ -74,11 +127,10 @@ func (s *serviceImpl) Serve(ctx context.Context) (gErr error) { defer recovery.Err(&gErr) s.err = nil - s.metric.Restart.Add(1) log.Info(ctx).Str("service", s.name).Msg("start service") err := s.fn(ctx) - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("non-context error, service=%s meta=%v err=%w", s.name, s.Metric(), err) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("service %s exited with error: %w", s.name, err) } return err } diff --git a/core/supervisor/spec.go b/core/supervisor/spec.go deleted file mode 100644 index a516f0b6a..000000000 --- a/core/supervisor/spec.go +++ /dev/null @@ -1,61 +0,0 @@ -package supervisor - -import ( - "log/slog" - "time" - - "github.com/pubgo/funk/v2/log" - "github.com/thejerf/suture/v4" -) - -const ServiceTimeout = 10 * time.Second - -func SpecWithDebugLogger() suture.Spec { - return spec(func(e suture.Event) { log.Debug().Msg(e.String()) }) -} - -func SpecWithInfoLogger() suture.Spec { - return spec(infoEventHook()) -} - -func spec(eventHook suture.EventHook) suture.Spec { - return suture.Spec{ - EventHook: eventHook, - Timeout: ServiceTimeout, - PassThroughPanics: true, - DontPropagateTermination: false, - } -} - -// infoEventHook prints service failures and failures to stop services at level -// info. All other events and identical, consecutive failures are logged at -// debug only. -func infoEventHook() suture.EventHook { - var prevTerminate suture.EventServiceTerminate - return func(ei suture.Event) { - m := ei.Map() - l := slog.With("supervisor", m["supervisor_name"], "service", m["service_name"]) - switch e := ei.(type) { - case suture.EventStopTimeout: - l.Warn("Service failed to terminate in a timely manner") - case suture.EventServicePanic: - l.Error("Caught a service panic, which shouldn't happen") - l.Warn(e.String()) //nolint:sloglint - case suture.EventServiceTerminate: - if e.ServiceName == prevTerminate.ServiceName && e.Err == prevTerminate.Err { - l.Debug("Service failed repeatedly", e.Err) - } else { - l.Warn("Service failed", e.Err) - } - prevTerminate = e - l.Debug(e.String()) // Contains some backoff statistics - case suture.EventBackoff: - l.Debug("Exiting the backoff state") - case suture.EventResume: - l.Debug("Too many service failures - entering the backoff state") - default: - l.Warn("Unknown suture supervisor event", slog.Any("type", e.Type())) - l.Warn(e.String()) //nolint:lint - } - } -} diff --git a/core/supervisor/types.go b/core/supervisor/types.go new file mode 100644 index 000000000..33e3294a4 --- /dev/null +++ b/core/supervisor/types.go @@ -0,0 +1,157 @@ +// Service supervision and lifecycle management for lava services + +package supervisor + +import ( + "context" + "time" +) + +// ServiceStatus 服务状态 +type ServiceStatus string + +const ( + StatusIdle ServiceStatus = "idle" // 空闲,未启动 + StatusRunning ServiceStatus = "running" // 运行中 + StatusStopped ServiceStatus = "stopped" // 已停止(手动) + StatusError ServiceStatus = "error" // 错误状态 + StatusCrashing ServiceStatus = "crashing" // 崩溃循环中 + StatusFailed ServiceStatus = "failed" // 已失败(达到重启上限) +) + +// Metric 服务指标 +type Metric struct { + Name string `json:"name"` // 服务名称 + Status ServiceStatus `json:"status"` // 当前状态 + StartCount uint32 `json:"start_count"` // 启动次数 + ErrorCount uint32 `json:"error_count"` // 错误次数 + SuccessCount uint32 `json:"success_count"` // 成功退出次数 + ConsecFailures uint32 `json:"consec_failures"` // 连续失败次数 + LastError string `json:"last_error"` // 最后一次错误信息 + LastErrorTime time.Time `json:"last_error_time"` // 最后一次错误时间 + LastStartTime time.Time `json:"last_start_time"` // 最后一次启动时间 + LastStopTime time.Time `json:"last_stop_time"` // 最后一次停止时间 + CurrentUptime time.Duration `json:"current_uptime"` // 当前运行时长 + TotalUptime time.Duration `json:"total_uptime"` // 总运行时长 + AverageUptime time.Duration `json:"average_uptime"` // 平均运行时长 + CreatedAt time.Time `json:"created_at"` // 服务创建时间 + CurrentDelay time.Duration `json:"current_delay"` // 当前重启延迟 + RestartsInWindow uint32 `json:"restarts_in_window"` // 窗口期内重启次数 +} + +// Service 服务接口 +type Service interface { + Name() string + Error() error + String() string + Serve(ctx context.Context) error + Metric() *Metric +} + +// RestartPolicy 重启策略 +type RestartPolicy int + +const ( + // RestartAlways 总是重启(除非手动停止) + RestartAlways RestartPolicy = iota + // RestartOnFailure 仅在失败时重启 + RestartOnFailure + // RestartNever 从不自动重启 + RestartNever +) + +// ServiceConfig 服务配置 +type ServiceConfig struct { + // RestartPolicy 重启策略 + RestartPolicy RestartPolicy + // MaxRestarts 最大重启次数,0 表示无限制 + MaxRestarts int + // RestartDelay 初始重启延迟 + RestartDelay time.Duration + // MaxRestartDelay 最大重启延迟(用于指数退避) + MaxRestartDelay time.Duration + // RestartWindow 重启计数窗口期,在此期间内的重启会被计数 + // 如果服务运行超过此时间后崩溃,重启计数会重置 + RestartWindow time.Duration + // MaxRestartsInWindow 窗口期内最大重启次数,超过则标记为 failed + MaxRestartsInWindow int + // BackoffMultiplier 退避乘数,默认 2.0 + BackoffMultiplier float64 + // AutoStart 是否自动启动,默认 true + AutoStart bool +} + +// DefaultServiceConfig 默认服务配置 +func DefaultServiceConfig() ServiceConfig { + return ServiceConfig{ + AutoStart: true, + RestartPolicy: RestartAlways, + MaxRestarts: 0, // 无限制 + RestartDelay: time.Second, // 初始 1 秒 + MaxRestartDelay: time.Minute, // 最大 1 分钟 + RestartWindow: 5 * time.Minute, // 5 分钟窗口 + MaxRestartsInWindow: 5, // 窗口内最多 5 次 + BackoffMultiplier: 2.0, // 每次翻倍 + } +} + +// Option 配置选项 +type Option func(*ServiceConfig) + +// WithAutoStart 设置是否自动启动 +func WithAutoStart(autoStart bool) Option { + return func(c *ServiceConfig) { + c.AutoStart = autoStart + } +} + +// WithRestartPolicy 设置重启策略 +func WithRestartPolicy(policy RestartPolicy) Option { + return func(c *ServiceConfig) { + c.RestartPolicy = policy + } +} + +// WithMaxRestarts 设置最大重启次数 +func WithMaxRestarts(n int) Option { + return func(c *ServiceConfig) { + c.MaxRestarts = n + } +} + +// WithRestartDelay 设置重启延迟 +func WithRestartDelay(d time.Duration) Option { + return func(c *ServiceConfig) { + c.RestartDelay = d + } +} + +// WithBackoff 设置退避策略 +func WithBackoff(maxDelay time.Duration, multiplier float64) Option { + return func(c *ServiceConfig) { + c.MaxRestartDelay = maxDelay + c.BackoffMultiplier = multiplier + } +} + +// WithRestartWindow 设置重启窗口 +func WithRestartWindow(window time.Duration, maxRestarts int) Option { + return func(c *ServiceConfig) { + c.RestartWindow = window + c.MaxRestartsInWindow = maxRestarts + } +} + +// ServiceInfo 包含服务指标和运行时状态 +type ServiceInfo struct { + *Metric + Stopped bool `json:"stopped"` + Failed bool `json:"failed"` + RestartCount int `json:"restart_count"` + ConsecFailures int `json:"consec_failures"` + WindowRestarts int `json:"window_restarts"` + CurrentDelay time.Duration `json:"current_delay_ns"` + CurrentDelayStr string `json:"current_delay"` + WindowStart time.Time `json:"window_start"` + LastServiceStart time.Time `json:"last_service_start"` +} diff --git a/core/tunnel/README.md b/core/tunnel/README.md new file mode 100644 index 000000000..8bafebe78 --- /dev/null +++ b/core/tunnel/README.md @@ -0,0 +1,527 @@ +# Tunnel - 服务注册监控网关 + +服务注册监控网关模块,采用**反向连接**架构(类似 ngrok/frp)。 +服务节点上的 Agent **主动连接**到 Gateway,注册自己的服务,Gateway 被动接受连接并对外暴露这些服务。 + +## 核心特点 + +- **反向连接**:服务主动连接网关,无需开放服务端口 +- **内网穿透**:服务可以在内网/防火墙后,只要能出站连接 Gateway +- **服务聚合**:多个服务通过同一个 Gateway 对外暴露 +- **远程调试**:通过 Gateway 访问服务的 debug 接口进行远程监控 + +## 目录结构 + +``` +core/tunnel/ +├── doc.go # 包文档(详细 API 说明) +├── types.go # 核心类型和接口定义 +├── config.go # 配置结构定义 +├── config.yaml # 配置示例文件 +├── errors.go # 错误定义 +├── transport.go # 传输层注册表和工厂 +├── README.md # 本文档 +├── tunnelagent/ # Agent 实现包 +│ ├── agent.go # Agent 封装和 Builder +│ ├── impl.go # Agent 实现 +│ └── doc.go # 包文档 +├── tunnelgateway/ # Gateway 实现包 +│ ├── gateway.go # Gateway 封装和 Builder +│ ├── impl.go # Gateway 实现 +│ └── doc.go # 包文档 +├── yamux/ # yamux 传输协议实现(基于 TCP) +├── quic/ # QUIC 传输协议实现(基于 UDP) +├── kcp/ # KCP 传输协议实现(基于 UDP) +└── example/ # 使用示例 +``` + +## 架构图 + +``` + 外部请求 + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Gateway (公网/DMZ) │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ HTTP :8080 │ │ gRPC :9090 │ │ Debug :6060 │ <- 对外端口 │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ┌──────┴───────────────┴───────────────┴──────┐ │ +│ │ Service Router (按服务名路由) │ │ +│ └──────────────────────┬──────────────────────┘ │ +│ │ │ +│ ┌──────────────────────┴──────────────────────┐ │ +│ │ Session Manager (管理连接) │ │ +│ │ service-a ──> Session1 │ │ +│ │ service-b ──> Session2 │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ │ +│ Listener :7000 <- 接受 Agent 连接 │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + ┌──────────────┼──────────────┐ + │ │ │ + ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ + │ Tunnel │ │ Tunnel │ │ Tunnel │ <- 主动出站连接 + │ Session 1 │ │ Session 2 │ │ Session 3 │ (yamux 多路复用) + └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ + │ │ │ +┌───────────┴──┐ ┌────────┴───┐ ┌───────┴────┐ +│ Service A │ │ Service B │ │ Service C │ <- 内网服务节点 +│ (内网) │ │ (内网) │ │ (内网) │ +│ │ │ │ │ │ +│ ┌──────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │ +│ │Agent │─┼──┼─│Agent │─┼──┼─│Agent │ │ <- 主动连接 Gateway +│ └────┬─────┘ │ │ └───┬────┘ │ │ └───┬────┘ │ +│ │ │ │ │ │ │ │ │ +│ ┌────┴─────┐ │ │ ┌───┴────┐ │ │ ┌───┴────┐ │ +│ │本地服务 │ │ │ │本地服务│ │ │ │本地服务│ │ +│ │HTTP/gRPC │ │ │ │HTTP │ │ │ │Debug │ │ +│ │Debug │ │ │ └────────┘ │ │ └────────┘ │ +│ └──────────┘ │ └────────────┘ └────────────┘ +└──────────────┘ +``` + +## 工作流程 + +``` +1. Agent 启动,主动连接 Gateway + Agent ─────────────────────────────────────> Gateway:7000 + TCP + yamux + +2. Agent 注册服务信息 + Agent ──── [Register] {name, endpoints} ───> Gateway + Agent <─── [Ack] ──────────────────────────── Gateway + +3. Agent 保持心跳 + Agent ──── [Heartbeat] ────────────────────> Gateway (每30秒) + +4. 外部请求到达 Gateway + Client ──── HTTP Request ──────────────────> Gateway:8080 + +5. Gateway 通过已建立的 tunnel 转发请求 + Gateway ──── [HTTPRequest] ────────────────> Agent + (通过 yamux stream) + +6. Agent 转发到本地服务 + Agent ──────────────────────────────────────> localhost:8080 + +7. 响应原路返回 + localhost:8080 ──> Agent ──> Gateway ──> Client +``` + +## 快速开始 + +### 1. 部署 Gateway(公网服务器) + +Gateway 部署在公网可访问的服务器上,被动等待服务连接。 + +```go +import ( + "context" + "github.com/pubgo/lava/v2/core/tunnel/tunnelgateway" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // 注册 yamux 传输 +) + +func main() { + ctx := context.Background() + + // 方式一:直接创建 + gw := tunnelgateway.New(&tunnelgateway.Config{ + ListenAddr: ":7000", // Agent 连接端口 + Transport: "yamux", + HTTPPort: 8080, // 对外暴露的 HTTP 端口 + GRPCPort: 9090, // 对外暴露的 gRPC 端口 + DebugPort: 6060, // 对外暴露的 Debug 端口 + }) + + // 方式二:使用 Builder 模式 + gw, err := tunnelgateway.NewBuilder(). + WithListenAddr(":7000"). + WithTransport("yamux"). + WithHTTPPort(8080). + WithDebugPort(6060). + Build() + if err != nil { + log.Fatal(err) + } + + if err := gw.Start(ctx); err != nil { + log.Fatal(err) + } + defer gw.Stop(ctx) + + // Gateway 现在等待 Agent 连接... + // 当 Agent 连接并注册服务后,可以通过 Services() 查看 + for _, svc := range gw.Services() { + fmt.Printf("已注册服务: %s v%s\n", svc.Name, svc.Version) + } +} +``` + +### 2. 部署 Agent(内网服务节点) + +Agent 部署在服务所在的机器上(可以是内网),主动连接到 Gateway。 + +```go +import ( + "context" + "github.com/pubgo/lava/v2/core/tunnel" + "github.com/pubgo/lava/v2/core/tunnel/tunnelagent" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +) + +func main() { + ctx := context.Background() + + // 方式一:直接创建 + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: "gateway.example.com:7000", + Transport: "yamux", + ServiceName: "my-service", + ServiceVersion: "1.0.0", + Endpoints: []tunnel.EndpointConfig{ + {Type: "http", LocalAddr: "localhost:8080"}, + {Type: "debug", LocalAddr: "localhost:6060"}, + }, + }) + + // 方式二:使用 Builder 模式 + agent, err := tunnelagent.NewBuilder(). + WithGatewayAddr("gateway.example.com:7000"). + WithServiceName("my-service"). + WithServiceVersion("1.0.0"). + AddEndpoint("http", "localhost:8080", "/api"). + AddEndpoint("debug", "localhost:6060", "/debug"). + WithReconnectInterval(5). + Build() + if err != nil { + log.Fatal(err) + } + + // 启动 Agent,会自动: + // 1. 连接到 Gateway + // 2. 注册服务 + // 3. 保持心跳 + // 4. 断线自动重连 + if err := agent.Start(ctx); err != nil { + log.Fatal(err) + } + defer agent.Stop(ctx) + + // 之后外部可以通过 Gateway 访问本地服务: + // http://gateway.example.com:8080/my-service/api -> localhost:8080 + // gateway.example.com:9090 (gRPC) -> localhost:9090 + // http://gateway.example.com:6060/my-service/debug -> localhost:6060 + + // 检查状态 + if agent.Status() == tunnelagent.StatusConnected { + fmt.Println("已连接到 Gateway") + } +} +``` + +### 3. 运行示例 + +```bash +# 运行完整示例(Gateway + Agent + Backend) +go run ./core/tunnel/example/main.go + +# 使用不同传输协议 +go run ./core/tunnel/example/main.go -transport=quic +go run ./core/tunnel/example/main.go -transport=kcp + +# 分别启动(不同终端) +go run ./core/tunnel/example/main.go -mode=backend +go run ./core/tunnel/example/main.go -mode=gateway +go run ./core/tunnel/example/main.go -mode=agent + +# 验证请求路由(默认 service=demo-svc) +curl http://127.0.0.1:18080/demo-svc/hello +``` + +### 4. 集成调试接口 + +调试接口由 `core/tunnel/tunneldebug` 包提供,可以集成到服务的调试端点: + +```go +import "github.com/pubgo/lava/v2/core/tunnel/tunneldebug" + +// 设置 Gateway 实例 +tunneldebug.SetGateway(gw.Inner()) + +// 设置 Agent 实例 +tunneldebug.SetAgent(agent.Inner()) +``` + +## 使用场景 + +### 典型场景:内网服务暴露 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 开发者电脑 │ │ 公网 Gateway │ │ 内网服务器 │ +│ │ │ │ │ │ +│ 浏览器/curl │────>│ :8080 (HTTP) │<────│ Agent │ +│ │ │ :9090 (gRPC) │ │ └─ 主动连接 │ +│ │ │ :6060 (Debug) │ │ │ +│ │ │ │ │ 本地服务 │ +│ │ │ :7000 (Agent) │ │ └─ :8080 │ +│ │ │ ↑ 被动 │ │ └─ :9090 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 应用场景 + +1. **远程调试**:通过 Gateway 访问内网服务的 pprof、metrics 等调试接口 +2. **服务聚合**:多个内网服务通过同一个 Gateway 对外暴露 +3. **安全访问**:内网服务无需开放端口,只需能出站连接 Gateway +4. **临时暴露**:开发测试时临时将本地服务暴露到公网 + +## 配置说明 + +### 网关配置 + +```yaml +tunnel: + gateway: + enabled: true + listen_addr: ":7000" # Agent 连接地址 + transport: yamux # 传输协议 + http_port: 8080 # HTTP 代理端口 + grpc_port: 9090 # gRPC 代理端口 + debug_port: 6060 # Debug 代理端口 + heartbeat_interval: 30 # 心跳间隔(秒) + heartbeat_timeout: 90 # 心跳超时(秒) + health_check_interval: 30 # 健康检查间隔(秒) + tls: + enabled: false + cert_file: "" + key_file: "" +``` + +### 代理客户端配置 + +```yaml +tunnel: + agent: + enabled: true + gateway_addr: "gateway.example.com:7000" + transport: yamux + service_name: my-service + service_version: "1.0.0" + metadata: + env: production + endpoints: + - type: http + local_addr: "localhost:8080" + path: /api + - type: grpc + local_addr: "localhost:9090" + - type: debug + local_addr: "localhost:6060" + path: /debug + heartbeat_interval: 30 # 心跳间隔(秒) + reconnect_interval: 5 # 重连间隔(秒) + max_reconnect_attempts: 0 # 0=无限重试 +``` + +## 核心接口 + +### Transport - 传输层接口 + +```go +type Transport interface { + Name() string + Dial(ctx context.Context, addr string) (Session, error) + Listen(ctx context.Context, addr string) (Listener, error) +} +``` + +### Session - 会话接口 + +```go +type Session interface { + io.Closer + Open(ctx context.Context) (Stream, error) + Accept() (Stream, error) + IsClosed() bool + NumStreams() int + LocalAddr() net.Addr + RemoteAddr() net.Addr +} +``` + +### Agent - 代理客户端接口 + +```go +type Agent interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + Register(ctx context.Context, service *ServiceInfo) error + Deregister(ctx context.Context, serviceName string) error + Status() AgentStatus +} +``` + +### Gateway - 网关接口 + +```go +type Gateway interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + Services() []*ServiceInfo + GetService(name string) (*ServiceInfo, error) + Status() GatewayStatus + Forward(ctx context.Context, serviceName string, endpointType EndpointType, conn net.Conn) error +} +``` + +## 调试端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/tunnel/` | GET | 概览 HTML 页面 | +| `/tunnel/gateway` | GET | 网关状态 (JSON) | +| `/tunnel/gateway/services` | GET | 所有注册服务列表 | +| `/tunnel/gateway/services/:name` | GET | 指定服务详情 | +| `/tunnel/agent` | GET | 代理客户端状态 | + +## 状态说明 + +### AgentStatus + +| 值 | 说明 | +|----|------| +| `StatusDisconnected` | 未连接 | +| `StatusConnecting` | 连接中 | +| `StatusConnected` | 已连接 | +| `StatusReconnecting` | 重连中 | + +### GatewayStatus + +| 值 | 说明 | +|----|------| +| `GatewayStatusStopped` | 已停止 | +| `GatewayStatusStarting` | 启动中 | +| `GatewayStatusRunning` | 运行中 | +| `GatewayStatusStopping` | 停止中 | + +## 传输协议 + +| 协议 | 常量 | 状态 | 说明 | +|------|------|------|------| +| yamux | `TransportYamux` | ✅ 已实现 | 基于 TCP 的多路复用 | +| QUIC | `TransportQUIC` | ✅ 已实现 | 基于 UDP 的多路复用,低延迟、0-RTT | +| KCP | `TransportKCP` | ✅ 已实现 | 基于 UDP 的可靠传输,弱网优化 | + +### 协议选择指南 + +| 场景 | 推荐协议 | 原因 | +|------|----------|------| +| 通用场景 | yamux | 稳定可靠,兼容性好 | +| 高延迟网络 | QUIC/KCP | 0-RTT 连接,快速恢复 | +| 弱网环境 | KCP | 激进重传策略,抗丢包 | +| 需要 TLS 1.3 | QUIC | 内置加密,更安全 | + +### 协议特性对比 + +| 特性 | yamux | QUIC | KCP | +|------|-------|------|-----| +| 传输层 | TCP | UDP | UDP | +| 多路复用 | ✅ | ✅ | ✅ (smux) | +| 连接迁移 | ❌ | ✅ | ❌ | +| 0-RTT | ❌ | ✅ | ❌ | +| 内置加密 | ❌ | ✅ | ❌ | +| 代理穿透 | ❌ | ❌ | ❌ | +| 抗丢包 | 一般 | 好 | 优秀 | +| CPU 占用 | 低 | 中 | 中 | + +### 自定义传输协议 + +```go +func init() { + tunnel.RegisterTransport("custom", func(opts *tunnel.TransportOptions) (tunnel.Transport, error) { + return &customTransport{opts: opts}, nil + }) +} + +type customTransport struct { + opts *tunnel.TransportOptions +} + +func (t *customTransport) Name() string { return "custom" } +func (t *customTransport) Dial(ctx context.Context, addr string) (tunnel.Session, error) { ... } +func (t *customTransport) Listen(ctx context.Context, addr string) (tunnel.Listener, error) { ... } +``` + +## 通信协议 + +Agent 和 Gateway 使用长度前缀的 JSON 消息通信: + +``` +┌────────────┬─────────────────────────────┐ +│ Length (4B)│ JSON Message │ +│ uint32 BE │ { "type": 1, "service":... │ +└────────────┴─────────────────────────────┘ +``` + +### 消息类型 + +| 类型 | 值 | 说明 | +|------|-----|------| +| `MessageTypeRegister` | 1 | 服务注册 | +| `MessageTypeDeregister` | 2 | 服务注销 | +| `MessageTypeHeartbeat` | 3 | 心跳 | +| `MessageTypeHTTPRequest` | 9 | HTTP 请求转发 | +| `MessageTypeGRPCRequest` | 10 | gRPC 请求转发 | +| `MessageTypeDebugRequest` | 11 | Debug 请求转发 | + +## 错误类型 + +```go +ErrSessionClosed // 会话已关闭 +ErrStreamClosed // 流已关闭 +ErrConnectionFailed // 连接失败 +ErrServiceNotFound // 服务未找到 +ErrServiceAlreadyExists // 服务已存在 +ErrInvalidMessage // 无效消息 +ErrTimeout // 超时 +ErrTransportNotSupported // 不支持的传输协议 +ErrGatewayNotConnected // 未连接到网关 +ErrAgentNotRunning // 代理客户端未运行 +ErrAgentAlreadyRunning // 代理客户端已运行 +ErrGatewayAlreadyRunning // 网关已运行 +``` + +## 注意事项 + +1. **必须导入传输协议**:使用前需要导入对应的传输协议实现包 + ```go + // 根据需要导入一个或多个传输协议 + import _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // TCP + 多路复用(推荐) + import _ "github.com/pubgo/lava/v2/core/tunnel/quic" // UDP + 低延迟 + import _ "github.com/pubgo/lava/v2/core/tunnel/kcp" // UDP + 弱网优化 + ``` + +2. **自动重连**:Agent 断线后会自动重连,可通过 `ReconnectInterval` 配置重连间隔 + +3. **端点地址格式**:`Endpoint.Address` 应为完整的 `host:port` 格式 + +4. **心跳机制**:超过 `HeartbeatTimeout` 未收到心跳,服务会被标记为离线 + +5. **多服务支持**:单个 Agent 可以注册多个服务 + +## 依赖 + +### 核心依赖 +- `github.com/gofiber/fiber/v3` - Fiber Web 框架(调试接口) +- `github.com/pubgo/funk/v2/log` - 日志库 + +### 传输层依赖 +- `github.com/libp2p/go-yamux/v5` - yamux 多路复用实现 +- `github.com/quic-go/quic-go` - QUIC 协议实现 +- `github.com/xtaci/kcp-go/v5` - KCP 协议实现 +- `github.com/xtaci/smux` - smux 多路复用(KCP 使用) diff --git a/core/tunnel/config.go b/core/tunnel/config.go new file mode 100644 index 000000000..a8d09b53d --- /dev/null +++ b/core/tunnel/config.go @@ -0,0 +1,145 @@ +package tunnel + +// GatewayConfig 网关配置 +type GatewayConfig struct { + // ListenAddr 监听地址 + ListenAddr string `yaml:"listen_addr"` + // Transport 传输协议: yamux, quic, kcp + Transport string `yaml:"transport"` + // TransportOptions 传输层选项 + TransportOptions *TransportOptions `yaml:"transport_options"` + // HTTPPort HTTP 服务端口 + HTTPPort int `yaml:"http_port"` + // GRPCPort gRPC 服务端口 + GRPCPort int `yaml:"grpc_port"` + // DebugPort Debug 服务端口 + DebugPort int `yaml:"debug_port"` + // HeartbeatInterval 心跳间隔(秒) + HeartbeatInterval int `yaml:"heartbeat_interval"` + // HeartbeatTimeout 心跳超时(秒) + HeartbeatTimeout int `yaml:"heartbeat_timeout"` + // HealthCheckInterval 健康检查间隔(秒) + HealthCheckInterval int `yaml:"health_check_interval"` + // TLS TLS 配置 + TLS TLSConfig `yaml:"tls"` +} + +// AgentConfig 代理客户端配置 +type AgentConfig struct { + // GatewayAddr 网关地址 + GatewayAddr string `yaml:"gateway_addr"` + // Transport 传输协议 + Transport string `yaml:"transport"` + // TransportOptions 传输层选项 + TransportOptions *TransportOptions `yaml:"transport_options"` + // ServiceID 服务ID,如果为空则自动生成 + ServiceID string `yaml:"service_id"` + // ServiceName 服务名称 + ServiceName string `yaml:"service_name"` + // ServiceVersion 服务版本 + ServiceVersion string `yaml:"service_version"` + // Metadata 服务元数据 + Metadata map[string]string `yaml:"metadata"` + // Endpoints 要暴露的端点 + Endpoints []EndpointConfig `yaml:"endpoints"` + // HeartbeatInterval 心跳间隔(秒) + HeartbeatInterval int `yaml:"heartbeat_interval"` + // ReconnectInterval 重连间隔(秒) + ReconnectInterval int `yaml:"reconnect_interval"` + // MaxReconnectAttempts 最大重连次数,0 表示无限重试 + MaxReconnectAttempts int `yaml:"max_reconnect_attempts"` + // TLS TLS 配置 + TLS TLSConfig `yaml:"tls"` +} + +// EndpointConfig 端点配置 +type EndpointConfig struct { + // Type 端点类型: http, grpc, debug + Type string `yaml:"type"` + // LocalAddr 本地地址 + LocalAddr string `yaml:"local_addr"` + // Path 暴露路径 + Path string `yaml:"path"` + // Metadata 端点元数据 + Metadata map[string]string `yaml:"metadata"` +} + +// TLSConfig TLS 配置 +type TLSConfig struct { + // Enabled 是否启用 TLS + Enabled bool `yaml:"enabled"` + // CertFile 证书文件 + CertFile string `yaml:"cert_file"` + // KeyFile 私钥文件 + KeyFile string `yaml:"key_file"` + // CAFile CA 证书文件 + CAFile string `yaml:"ca_file"` + // Insecure 是否跳过证书验证 + Insecure bool `yaml:"insecure"` + // MinVersion 最小 TLS 版本 (e.g. "TLS12", "TLS13") + MinVersion string `yaml:"min_version"` + // CipherSuites 密码套件列表 (e.g. ["TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"]) + CipherSuites []string `yaml:"cipher_suites"` + // ClientAuth 客户端认证模式 ("NoClientCert", "RequestClientCert", "RequireAnyClientCert", "VerifyClientCertIfGiven", "RequireAndVerifyClientCert") + ClientAuth string `yaml:"client_auth"` + // SessionCacheSize 会话缓存大小 + SessionCacheSize int `yaml:"session_cache_size"` + // SessionTimeout 会话超时时间(秒) + SessionTimeout int `yaml:"session_timeout"` +} + +// TransportOptions 传输层选项 +type TransportOptions struct { + // EnableTLS 是否启用 TLS + EnableTLS bool + // CertFile 证书文件 + CertFile string + // KeyFile 私钥文件 + KeyFile string + // CAFile CA 证书文件 + CAFile string + // Insecure 是否跳过证书验证 + Insecure bool + // MaxStreams 最大流数量 + MaxStreams int + // KeepAliveInterval 保活间隔 + KeepAliveInterval int + // ConnectionWriteTimeout 连接写超时(秒) + ConnectionWriteTimeout int + // StreamOpenTimeout 流打开超时(秒) + StreamOpenTimeout int +} + +// DefaultTransportOptions 默认传输层选项 +func DefaultTransportOptions() *TransportOptions { + return &TransportOptions{ + MaxStreams: 256, + KeepAliveInterval: 30, + ConnectionWriteTimeout: 10, + StreamOpenTimeout: 30, + } +} + +// DefaultGatewayConfig 默认网关配置 +func DefaultGatewayConfig() GatewayConfig { + return GatewayConfig{ + ListenAddr: ":7007", + Transport: "yamux", + HTTPPort: 8080, + GRPCPort: 9090, + DebugPort: 6060, + HeartbeatInterval: 30, + HeartbeatTimeout: 90, + HealthCheckInterval: 30, + } +} + +// DefaultAgentConfig 默认代理客户端配置 +func DefaultAgentConfig() AgentConfig { + return AgentConfig{ + Transport: "yamux", + HeartbeatInterval: 30, + ReconnectInterval: 5, + MaxReconnectAttempts: 0, // 无限重试 + } +} diff --git a/core/tunnel/config.yaml b/core/tunnel/config.yaml new file mode 100644 index 000000000..bd83c5ae5 --- /dev/null +++ b/core/tunnel/config.yaml @@ -0,0 +1,99 @@ +# Tunnel 配置示例 +# 服务注册监控网关配置 +# +# 架构说明: +# - Gateway: 部署在公网,被动接受 Agent 连接 +# - Agent: 部署在服务节点(可以是内网),主动连接 Gateway +# +# 连接方向:Agent -> Gateway (反向代理) +# 请求流向:外部请求 -> Gateway -> Agent -> 本地服务 + +tunnel: + # ============================================ + # Gateway 配置 - 部署在公网/DMZ 服务器上 + # ============================================ + gateway: + # 是否启用网关 + enabled: false + # 监听地址 - Agent 主动连接此端口 + listen_addr: ":7000" + # 传输协议: yamux, quic, http, kcp + transport: yamux + # 传输层选项 + transport_options: + max_streams: 256 + keep_alive_interval: 30 + connection_write_timeout: 10 + stream_open_timeout: 30 + # 对外暴露的端口 - 外部请求通过这些端口访问内网服务 + http_port: 8080 # 外部 HTTP 请求入口 + grpc_port: 9090 # 外部 gRPC 请求入口 + debug_port: 6060 # 外部 Debug 请求入口 + # 心跳间隔(秒) + heartbeat_interval: 30 + # 心跳超时(秒)- 超过此时间未收到心跳认为 Agent 离线 + heartbeat_timeout: 90 + # 健康检查间隔(秒) + health_check_interval: 30 + # TLS 配置 + tls: + enabled: false + cert_file: "" + key_file: "" + ca_file: "" + insecure: false + + # ============================================ + # Agent 配置 - 部署在服务所在的节点上 + # ============================================ + agent: + # 是否启用代理客户端 + enabled: false + # Gateway 地址 - Agent 主动连接此地址 + gateway_addr: "gateway.example.com:7000" + # 传输协议 + transport: yamux + # 传输层选项 + transport_options: + max_streams: 256 + keep_alive_interval: 30 + # 服务ID(可选,为空则自动生成) + service_id: "" + # 服务名称 - 用于在 Gateway 上标识此服务 + service_name: my-service + # 服务版本 + service_version: "1.0.0" + # 服务元数据 + metadata: + env: production + region: cn-north-1 + # 要暴露的本地端点 - Gateway 会代理这些端点 + endpoints: + - type: http + local_addr: "localhost:8080" # 本地 HTTP 服务地址 + path: /api + metadata: + doc: "HTTP API 接口" + - type: grpc + local_addr: "localhost:9090" # 本地 gRPC 服务地址 + path: "" + metadata: + doc: "gRPC 服务接口" + - type: debug + local_addr: "localhost:6060" # 本地 Debug 服务地址 + path: /debug + metadata: + doc: "pprof/metrics 调试接口" + # 心跳间隔(秒) + heartbeat_interval: 30 + # 重连间隔(秒)- 与 Gateway 断开后的重连间隔 + reconnect_interval: 5 + # 最大重连次数,0 表示无限重试 + max_reconnect_attempts: 0 + # TLS 配置 + tls: + enabled: false + cert_file: "" + key_file: "" + ca_file: "" + insecure: false diff --git a/core/tunnel/doc.go b/core/tunnel/doc.go new file mode 100644 index 000000000..9b6a98aeb --- /dev/null +++ b/core/tunnel/doc.go @@ -0,0 +1,445 @@ +// Package tunnel 提供服务代理网关功能 +// +// # 概述 +// +// tunnel 包实现了一个基于反向连接的服务代理网关系统(类似 ngrok/frp)。 +// 服务节点上的 Agent **主动连接**到 Gateway,注册自己的服务端点, +// 然后 Gateway 对外暴露这些服务的 HTTP API、gRPC 和 debug 调试接口。 +// +// 核心特点: +// - 反向连接:服务主动连接网关,网关被动接受连接 +// - 内网穿透:服务可以在内网/防火墙后,只要能出站连接 Gateway +// - 服务聚合:多个服务通过同一个 Gateway 对外暴露 +// - 远程调试:通过 Gateway 访问服务的 debug 接口进行远程监控 +// +// # 目录结构 +// +// core/tunnel/ +// ├── doc.go - 包文档 +// ├── types.go - 核心类型和接口定义 +// ├── config.go - 配置结构定义 +// ├── config.yaml - 配置示例文件 +// ├── errors.go - 错误定义 +// ├── transport.go - 传输层注册表和工厂 +// ├── tunnelagent/ - Agent 实现包 +// │ ├── agent.go - Agent 封装和 Builder +// │ ├── impl.go - Agent 实现 +// │ └── doc.go - 包文档 +// ├── tunnelgateway/ - Gateway 实现包 +// │ ├── gateway.go - Gateway 封装和 Builder +// │ ├── impl.go - Gateway 实现 +// │ └── doc.go - 包文档 +// └── yamux/ - yamux 传输协议实现 +// └── quic/ - QUIC 传输协议实现 +// └── kcp/ - KCP 传输协议实现 +// +// # 架构设计 +// +// 与传统网关(如 Nginx)不同,本系统采用反向连接架构: +// +// - 传统架构:客户端 -> 网关 -> 后端服务(网关主动连接后端) +// +// - 本系统: 后端服务(Agent) -> 网关(Gateway)(服务主动连接网关) +// +// 外部请求 +// │ +// ▼ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Gateway (公网/DMZ) │ +// │ │ +// │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +// │ │ HTTP :8080 │ │ gRPC :9090 │ │ Debug :6060 │ <- 对外端口 │ +// │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +// │ │ │ │ │ +// │ ┌──────┴───────────────┴───────────────┴──────┐ │ +// │ │ Service Router (按服务名路由) │ │ +// │ └──────────────────────┬──────────────────────┘ │ +// │ │ │ +// │ ┌──────────────────────┴──────────────────────┐ │ +// │ │ Session Manager (管理连接) │ │ +// │ │ service-a ──> Session1 │ │ +// │ │ service-b ──> Session2 │ │ +// │ └──────────────────────────────────────────────┘ │ +// │ │ │ +// │ Listener :7000 <- 接受 Agent 连接 (被动) │ +// └─────────────────────────────────────────────────────────────────┘ +// ▲ +// ┌──────────────┼──────────────┐ +// │ │ │ +// ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ +// │ Tunnel │ │ Tunnel │ │ Tunnel │ <- 主动出站连接 +// │ Session 1 │ │ Session 2 │ │ Session 3 │ (yamux 多路复用) +// └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ +// │ │ │ +// ┌───────────┴──┐ ┌────────┴───┐ ┌───────┴────┐ +// │ Service A │ │ Service B │ │ Service C │ <- 内网服务节点 +// │ (内网) │ │ (内网) │ │ (内网) │ +// │ │ │ │ │ │ +// │ ┌──────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │ +// │ │Agent │─┼──┼─│Agent │─┼──┼─│Agent │ │ <- 主动连接 Gateway +// │ └────┬─────┘ │ │ └───┬────┘ │ │ └───┬────┘ │ +// │ │ │ │ │ │ │ │ │ +// │ ┌────┴─────┐ │ │ ┌───┴────┐ │ │ ┌───┴────┐ │ +// │ │本地服务 │ │ │ │本地服务│ │ │ │本地服务│ │ +// │ │HTTP/gRPC │ │ │ │HTTP │ │ │ │Debug │ │ +// │ │Debug │ │ │ └────────┘ │ │ └────────┘ │ +// │ └──────────┘ │ └────────────┘ └────────────┘ +// └──────────────┘ +// +// # 工作流程 +// +// 1. Gateway 启动,监听 :7000 等待 Agent 连接 +// 2. Agent 启动,主动连接到 Gateway:7000 (TCP + yamux) +// 3. Agent 发送 Register 消息,注册自己的服务信息和端点 +// 4. Agent 定期发送 Heartbeat 保持连接 +// 5. 外部请求到达 Gateway:8080 +// 6. Gateway 根据服务名找到对应的 Agent Session +// 7. Gateway 通过 yamux stream 将请求转发给 Agent +// 8. Agent 将请求转发到本地服务 (localhost:8080) +// 9. 响应原路返回:本地服务 -> Agent -> Gateway -> 外部客户端 +// +// ## 接口定义 (types.go) +// +// Transport - 传输层接口,负责建立连接 +// ├── Name() string - 协议名称 +// ├── Dial(ctx, addr) (Session, error) - 客户端连接 +// └── Listen(ctx, addr) (Listener, error) - 服务端监听 +// +// Session - 会话接口,支持多路复用 +// ├── Open(ctx) (Stream, error) - 打开新流 +// ├── Accept() (Stream, error) - 接受新流 +// ├── Close() error - 关闭会话 +// ├── IsClosed() bool - 是否已关闭 +// ├── NumStreams() int - 活跃流数量 +// ├── LocalAddr() net.Addr - 本地地址 +// └── RemoteAddr() net.Addr - 远程地址 +// +// Stream - 流接口,单个请求/响应通道 +// ├── io.ReadWriteCloser - 读写关闭 +// ├── LocalAddr() / RemoteAddr() - 地址信息 +// └── SetDeadline() / SetReadDeadline() / SetWriteDeadline() +// +// Listener - 监听器接口 +// ├── Accept() (Session, error) - 接受新会话 +// ├── Close() error - 关闭监听 +// └── Addr() net.Addr - 监听地址 +// +// Agent - 代理客户端接口 +// ├── Start(ctx) error - 启动 +// ├── Stop(ctx) error - 停止 +// ├── Register(ctx, *ServiceInfo) error - 注册服务 +// ├── Deregister(ctx, serviceName) error - 注销服务 +// └── Status() AgentStatus - 获取状态 +// +// Gateway - 代理网关接口 +// ├── Start(ctx) error - 启动 +// ├── Stop(ctx) error - 停止 +// ├── Services() []*ServiceInfo - 获取所有服务 +// ├── GetService(name) (*ServiceInfo, error) - 获取指定服务 +// ├── Status() GatewayStatus - 获取状态 +// └── Forward(ctx, name, type, conn) error - 转发请求 +// +// ## 数据结构 +// +// ServiceInfo - 服务信息 +// ├── ID string - 服务唯一标识 +// ├── Name string - 服务名称 +// ├── Version string - 服务版本 +// ├── Metadata map[string]string - 元数据 +// ├── Endpoints []Endpoint - 端点列表 +// ├── RegisterTime time.Time - 注册时间 +// ├── LastHeartbeat time.Time - 最后心跳 +// └── Status ServiceStatus - 服务状态 +// +// Endpoint - 服务端点 +// ├── Type EndpointType - 类型: http/grpc/debug +// ├── Path string - 路径 +// ├── Port int - 端口 +// ├── Address string - 完整地址 +// └── Metadata map[string]string - 端点元数据 +// +// Message - 通信消息 +// ├── Type MessageType - 消息类型 +// ├── ID string - 消息ID +// ├── Service *ServiceInfo - 服务信息 +// ├── Payload []byte - 负载数据 +// └── Error string - 错误信息 +// +// ## 状态枚举 +// +// AgentStatus: +// - StatusDisconnected (0) - 未连接 +// - StatusConnecting (1) - 连接中 +// - StatusConnected (2) - 已连接 +// - StatusReconnecting (3) - 重连中 +// +// GatewayStatus: +// - GatewayStatusStopped (0) - 已停止 +// - GatewayStatusStarting (1) - 启动中 +// - GatewayStatusRunning (2) - 运行中 +// - GatewayStatusStopping (3) - 停止中 +// +// ServiceStatus: +// - ServiceStatusOnline - 在线 +// - ServiceStatusOffline - 离线 +// - ServiceStatusUnhealthy - 不健康 +// +// EndpointType: +// - EndpointTypeHTTP ("http") - HTTP 端点 +// - EndpointTypeGRPC ("grpc") - gRPC 端点 +// - EndpointTypeDebug ("debug") - Debug 端点 +// +// MessageType: +// - MessageTypeRegister (1) - 服务注册 +// - MessageTypeDeregister (2) - 服务注销 +// - MessageTypeHeartbeat (3) - 心跳 +// - MessageTypeRequest (4) - 请求 +// - MessageTypeResponse (5) - 响应 +// - MessageTypeStream (6) - 流数据 +// - MessageTypeAck (7) - 确认 +// - MessageTypeError (8) - 错误 +// - MessageTypeHTTPRequest (9) - HTTP 请求转发 +// - MessageTypeGRPCRequest (10) - gRPC 请求转发 +// - MessageTypeDebugRequest (11) - Debug 请求转发 +// +// # 传输协议 +// +// 支持的传输协议常量 (transport.go): +// +// TransportYamux = "yamux" - 基于 TCP 的多路复用 (已实现) +// TransportQUIC = "quic" - 基于 UDP 的多路复用 (已实现) +// TransportKCP = "kcp" - 基于 UDP 的可靠传输 (已实现) +// +// 注册自定义传输协议: +// +// tunnel.RegisterTransport("custom", func(opts *TransportOptions) (Transport, error) { +// return &customTransport{opts: opts}, nil +// }) +// +// # 配置说明 (config.go) +// +// ## GatewayConfig - 网关配置 +// +// Enabled bool - 是否启用 +// ListenAddr string - 监听地址 (默认 :7000) +// Transport string - 传输协议 (默认 yamux) +// TransportOptions *TransportOptions - 传输层选项 +// HTTPPort int - HTTP 代理端口 (默认 8080) +// GRPCPort int - gRPC 代理端口 (默认 9090) +// DebugPort int - Debug 代理端口 (默认 6060) +// HeartbeatInterval int - 心跳间隔秒数 (默认 30) +// HeartbeatTimeout int - 心跳超时秒数 (默认 90) +// HealthCheckInterval int - 健康检查间隔 (默认 30) +// TLS TLSConfig - TLS 配置 +// +// ## AgentConfig - 代理客户端配置 +// +// Enabled bool - 是否启用 +// GatewayAddr string - 网关地址 +// Transport string - 传输协议 (默认 yamux) +// TransportOptions *TransportOptions - 传输层选项 +// ServiceID string - 服务ID (可选) +// ServiceName string - 服务名称 +// ServiceVersion string - 服务版本 +// Metadata map[string]string - 元数据 +// Endpoints []EndpointConfig - 端点配置 +// HeartbeatInterval int - 心跳间隔秒数 (默认 30) +// ReconnectInterval int - 重连间隔秒数 (默认 5) +// MaxReconnectAttempts int - 最大重连次数 (0=无限) +// TLS TLSConfig - TLS 配置 +// +// ## TransportOptions - 传输层选项 +// +// EnableTLS bool - 启用 TLS +// CertFile string - 证书文件 +// KeyFile string - 私钥文件 +// CAFile string - CA 证书 +// Insecure bool - 跳过证书验证 +// MaxStreams int - 最大流数量 (默认 256) +// KeepAliveInterval int - 保活间隔秒数 (默认 30) +// ConnectionWriteTimeout int - 写超时秒数 (默认 10) +// StreamOpenTimeout int - 流打开超时 (默认 30) +// +// # 使用示例 +// +// ## 1. 部署 Gateway (公网服务器) +// +// Gateway 部署在公网可访问的服务器上,被动等待 Agent 连接。 +// +// import ( +// "context" +// "github.com/pubgo/lava/v2/core/tunnel/tunnelgateway" +// _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // 注册 yamux 传输 +// ) +// +// // 方式一:直接创建 +// gw := tunnelgateway.New(&tunnelgateway.Config{ +// ListenAddr: ":7000", // Agent 连接端口 +// Transport: "yamux", +// HTTPPort: 8080, // 对外暴露的 HTTP 端口 +// GRPCPort: 9090, // 对外暴露的 gRPC 端口 +// DebugPort: 6060, // 对外暴露的 Debug 端口 +// }) +// +// // 方式二:使用 Builder 模式 +// gw, err := tunnelgateway.NewBuilder(). +// WithListenAddr(":7000"). +// WithTransport("yamux"). +// WithHTTPPort(8080). +// WithDebugPort(6060). +// Build() +// if err != nil { +// log.Fatal(err) +// } +// +// if err := gw.Start(ctx); err != nil { +// log.Fatal(err) +// } +// defer gw.Stop(ctx) +// // Gateway 现在等待 Agent 连接... +// +// ## 2. 部署 Agent (内网服务节点) +// +// Agent 部署在服务所在的机器上(可以是内网),主动连接到 Gateway。 +// +// import ( +// "github.com/pubgo/lava/v2/core/tunnel" +// "github.com/pubgo/lava/v2/core/tunnel/tunnelagent" +// _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +// ) +// +// // 方式一:直接创建 +// agent := tunnelagent.New(&tunnelagent.Config{ +// GatewayAddr: "gateway.example.com:7000", +// Transport: "yamux", +// ServiceName: "my-service", +// ServiceVersion: "1.0.0", +// Endpoints: []tunnel.EndpointConfig{ +// {Type: "http", LocalAddr: "localhost:8080"}, +// {Type: "debug", LocalAddr: "localhost:6060"}, +// }, +// }) +// +// // 方式二:使用 Builder 模式 +// agent, err := tunnelagent.NewBuilder(). +// WithGatewayAddr("gateway.example.com:7000"). +// WithServiceName("my-service"). +// WithServiceVersion("1.0.0"). +// AddEndpoint("http", "localhost:8080", "/api"). +// AddEndpoint("debug", "localhost:6060", "/debug"). +// WithReconnectInterval(5). +// Build() +// if err != nil { +// log.Fatal(err) +// } +// +// // 启动 Agent,会自动: +// // 1. 连接到 Gateway +// // 2. 注册服务 +// // 3. 保持心跳 +// // 4. 断线自动重连 +// if err := agent.Start(ctx); err != nil { +// log.Fatal(err) +// } +// defer agent.Stop(ctx) +// +// // 之后外部可以通过 Gateway 访问本地服务: +// // http://gateway.example.com:8080/my-service/api -> localhost:8080 +// // gateway.example.com:9090 (gRPC) -> localhost:9090 +// // http://gateway.example.com:6060/my-service/debug -> localhost:6060 +// +// // 检查状态 +// if agent.Status() == tunnelagent.StatusConnected { +// fmt.Println("已连接到 Gateway") +// } +// +// ## 3. 集成调试接口 +// +// 调试接口由 core/tunnel/tunneldebug 包提供: +// +// import "github.com/pubgo/lava/v2/core/tunnel/tunneldebug" +// +// // 设置 Gateway 实例 +// tunneldebug.SetGateway(gw.Inner()) +// +// // 设置 Agent 实例 +// tunneldebug.SetAgent(agent.Inner()) +// +// ## 4. TLS 配置 +// +// agent, _ := tunnelagent.NewBuilder(). +// WithGatewayAddr("gateway.example.com:7000"). +// WithTLS(tunnel.TLSConfig{ +// Enabled: true, +// CertFile: "/path/to/client.crt", +// KeyFile: "/path/to/client.key", +// CAFile: "/path/to/ca.crt", +// }). +// Build() +// +// # 通信协议 +// +// Agent 和 Gateway 之间使用长度前缀的 JSON 消息进行通信: +// +// ┌────────────┬─────────────────────────────┐ +// │ Length (4B)│ JSON Message │ +// │ uint32 BE │ { "type": 1, "service":... │ +// └────────────┴─────────────────────────────┘ +// +// 消息流程: +// +// Agent Gateway +// │ │ +// │──── [Register] ServiceInfo ────────────>│ 服务注册 +// │ │ +// │<─── [Ack] ─────────────────────────────│ 确认 +// │ │ +// │──── [Heartbeat] ───────────────────────>│ 心跳 +// │ │ +// │<─── [HTTPRequest] ServiceInfo ─────────│ 请求转发 +// │ │ +// │──── [Stream] Response Data ────────────>│ 响应数据 +// │ │ +// │──── [Deregister] ServiceName ──────────>│ 服务注销 +// │ │ +// +// # 调试接口 +// +// 可用的调试端点: +// +// GET /tunnel/ - 概览 HTML 页面 +// GET /tunnel/gateway - 网关状态 (JSON) +// GET /tunnel/gateway/services - 所有注册服务 (JSON) +// GET /tunnel/gateway/services/:name - 指定服务详情 (JSON) +// GET /tunnel/agent - 代理客户端状态 (JSON) +// +// # 错误处理 (errors.go) +// +// ErrSessionClosed - 会话已关闭 +// ErrStreamClosed - 流已关闭 +// ErrConnectionFailed - 连接失败 +// ErrServiceNotFound - 服务未找到 +// ErrServiceAlreadyExists - 服务已存在 +// ErrInvalidMessage - 无效消息 +// ErrTimeout - 超时 +// ErrTransportNotSupported - 不支持的传输协议 +// ErrGatewayNotConnected - 未连接到网关 +// ErrAgentNotRunning - 代理客户端未运行 +// ErrAgentAlreadyRunning - 代理客户端已运行 +// ErrGatewayAlreadyRunning - 网关已运行 +// +// # 注意事项 +// +// 1. 必须导入传输协议实现包以注册协议: +// import _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +// +// 2. Agent 会自动重连,但需要确保网关地址可达 +// +// 3. 服务端点的 Address 字段应为完整的 host:port 格式 +// +// 4. 心跳超时后服务会被标记为离线并从网关移除 +// +// 5. 支持同一 Agent 注册多个服务 +// +// 6. Gateway 的 Forward 方法用于将外部请求转发到对应服务 +package tunnel diff --git a/core/tunnel/errors.go b/core/tunnel/errors.go new file mode 100644 index 000000000..a9f1a1cc7 --- /dev/null +++ b/core/tunnel/errors.go @@ -0,0 +1,30 @@ +package tunnel + +import "errors" + +var ( + // ErrSessionClosed 会话已关闭 + ErrSessionClosed = errors.New("tunnel: session closed") + // ErrStreamClosed 流已关闭 + ErrStreamClosed = errors.New("tunnel: stream closed") + // ErrConnectionFailed 连接失败 + ErrConnectionFailed = errors.New("tunnel: connection failed") + // ErrServiceNotFound 服务未找到 + ErrServiceNotFound = errors.New("tunnel: service not found") + // ErrServiceAlreadyExists 服务已存在 + ErrServiceAlreadyExists = errors.New("tunnel: service already exists") + // ErrInvalidMessage 无效消息 + ErrInvalidMessage = errors.New("tunnel: invalid message") + // ErrTimeout 超时 + ErrTimeout = errors.New("tunnel: timeout") + // ErrTransportNotSupported 不支持的传输协议 + ErrTransportNotSupported = errors.New("tunnel: transport not supported") + // ErrGatewayNotConnected 未连接到网关 + ErrGatewayNotConnected = errors.New("tunnel: gateway not connected") + // ErrAgentNotRunning 代理客户端未运行 + ErrAgentNotRunning = errors.New("tunnel: agent not running") + // ErrAgentAlreadyRunning 代理客户端已在运行 + ErrAgentAlreadyRunning = errors.New("tunnel: agent already running") + // ErrGatewayAlreadyRunning 网关已在运行 + ErrGatewayAlreadyRunning = errors.New("tunnel: gateway already running") +) diff --git a/core/tunnel/example/main.go b/core/tunnel/example/main.go new file mode 100644 index 000000000..2ab6c05ac --- /dev/null +++ b/core/tunnel/example/main.go @@ -0,0 +1,229 @@ +// Package main provides a simple example of using the tunnel package. +// +// This example demonstrates: +// - Starting a Gateway server +// - Connecting an Agent to the Gateway +// - Registering services and proxying HTTP requests +// +// Usage: +// +// # Run all components together (gateway + agent + backend) +// go run ./core/tunnel/example/main.go +// +// # Run with different transport protocols +// go run ./core/tunnel/example/main.go -transport=quic +// go run ./core/tunnel/example/main.go -transport=kcp +// +// # Run components separately (in different terminals) +// go run ./core/tunnel/example/main.go -mode=backend +// go run ./core/tunnel/example/main.go -mode=gateway +// go run ./core/tunnel/example/main.go -mode=agent +// +// # Verify request routing +// curl http://127.0.0.1:18080/demo-svc/hello +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/kcp" + _ "github.com/pubgo/lava/v2/core/tunnel/quic" + "github.com/pubgo/lava/v2/core/tunnel/tunnelagent" + "github.com/pubgo/lava/v2/core/tunnel/tunnelgateway" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +) + +var ( + mode = flag.String("mode", "all", "Run mode: gateway, agent, backend, or all") + transport = flag.String("transport", "yamux", "Transport protocol: yamux, quic, kcp") + gatewayAddr = flag.String("gateway-addr", "127.0.0.1:17000", "Gateway listen address") + httpPort = flag.Int("http-port", 18080, "HTTP proxy port") + backendAddr = flag.String("backend-addr", "127.0.0.1:18081", "Backend service address") + serviceName = flag.String("service", "demo-svc", "Service name for agent") +) + +func main() { + flag.Parse() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Println("\nShutting down...") + cancel() + }() + + switch *mode { + case "gateway": + runGateway(ctx) + case "agent": + runAgent(ctx) + case "backend": + runBackend(ctx) + case "all": + runAll(ctx) + default: + log.Fatalf("Unknown mode: %s", *mode) + } +} + +func runGateway(ctx context.Context) { + gw := tunnelgateway.New(&tunnelgateway.Config{ + ListenAddr: *gatewayAddr, + Transport: *transport, + HTTPPort: *httpPort, + }) + + if err := gw.Start(ctx); err != nil { + log.Fatalf("Gateway start failed: %v", err) + } + defer func() { + if err := gw.Stop(ctx); err != nil { + log.Printf("Gateway stop failed: %v", err) + } + }() + + fmt.Printf("Gateway started on %s (transport: %s)\n", *gatewayAddr, *transport) + fmt.Printf("HTTP proxy available at http://127.0.0.1:%d//\n", *httpPort) + + <-ctx.Done() +} + +func runAgent(ctx context.Context) { + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: *gatewayAddr, + Transport: *transport, + ServiceName: *serviceName, + Endpoints: []tunnel.EndpointConfig{ + {Type: "http", LocalAddr: *backendAddr}, + }, + }) + + if err := agent.Start(ctx); err != nil { + log.Fatalf("Agent start failed: %v", err) + } + defer func() { + if err := agent.Stop(ctx); err != nil { + log.Printf("Agent stop failed: %v", err) + } + }() + + fmt.Printf("Agent connected to %s (service: %s, backend: %s)\n", *gatewayAddr, *serviceName, *backendAddr) + + <-ctx.Done() +} + +func runBackend(ctx context.Context) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(map[string]any{ + "path": r.URL.Path, + "method": r.Method, + "headers": r.Header, + "time": time.Now().Format(time.RFC3339), + }); err != nil { + log.Printf("encode response failed: %v", err) + } + }) + + srv := &http.Server{Addr: *backendAddr, Handler: mux} + go func() { + <-ctx.Done() + if err := srv.Shutdown(context.Background()); err != nil { + log.Printf("backend shutdown failed: %v", err) + } + }() + + fmt.Printf("Backend server started on %s\n", *backendAddr) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("Backend server error: %v", err) + } +} + +func runAll(ctx context.Context) { + fmt.Println("Starting backend server...") + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(map[string]any{ + "message": "Hello from backend!", + "path": r.URL.Path, + "time": time.Now().Format(time.RFC3339), + }); err != nil { + log.Printf("encode response failed: %v", err) + } + }) + backendSrv := &http.Server{Addr: *backendAddr, Handler: mux} + go func() { + if err := backendSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("backend server error: %v", err) + } + }() + defer func() { + if err := backendSrv.Shutdown(ctx); err != nil { + log.Printf("backend shutdown failed: %v", err) + } + }() + time.Sleep(100 * time.Millisecond) + + fmt.Println("Starting gateway...") + gw := tunnelgateway.New(&tunnelgateway.Config{ + ListenAddr: *gatewayAddr, + Transport: *transport, + HTTPPort: *httpPort, + }) + if err := gw.Start(ctx); err != nil { + log.Fatalf("Gateway start failed: %v", err) + } + defer func() { + if err := gw.Stop(ctx); err != nil { + log.Printf("Gateway stop failed: %v", err) + } + }() + + fmt.Println("Starting agent...") + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: *gatewayAddr, + Transport: *transport, + ServiceName: *serviceName, + Endpoints: []tunnel.EndpointConfig{ + {Type: "http", LocalAddr: *backendAddr}, + }, + }) + if err := agent.Start(ctx); err != nil { + log.Fatalf("Agent start failed: %v", err) + } + defer func() { + if err := agent.Stop(ctx); err != nil { + log.Printf("Agent stop failed: %v", err) + } + }() + + time.Sleep(500 * time.Millisecond) + + fmt.Println() + fmt.Println("========================================") + fmt.Printf("Tunnel demo running (transport: %s)\n", *transport) + fmt.Println("========================================") + fmt.Printf("Gateway: %s\n", *gatewayAddr) + fmt.Printf("HTTP Proxy: http://127.0.0.1:%d\n", *httpPort) + fmt.Printf("Service: %s\n", *serviceName) + fmt.Printf("Backend: %s\n", *backendAddr) + fmt.Println() + fmt.Printf("Try: curl http://127.0.0.1:%d/%s/hello\n", *httpPort, *serviceName) + fmt.Println() + fmt.Println("Press Ctrl+C to stop") + + <-ctx.Done() +} diff --git a/core/tunnel/integration_test.go b/core/tunnel/integration_test.go new file mode 100644 index 000000000..37b48dd2b --- /dev/null +++ b/core/tunnel/integration_test.go @@ -0,0 +1,575 @@ +package tunnel_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/kcp" + _ "github.com/pubgo/lava/v2/core/tunnel/quic" + "github.com/pubgo/lava/v2/core/tunnel/tunnelagent" + "github.com/pubgo/lava/v2/core/tunnel/tunnelgateway" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +) + +func TestTransport_Yamux(t *testing.T) { runTransportTest(t, "yamux", 20000) } +func TestTransport_QUIC(t *testing.T) { runTransportTest(t, "quic", 20100) } +func TestTransport_KCP(t *testing.T) { runTransportTest(t, "kcp", 20300) } + +func runTransportTest(t *testing.T, transport string, basePort int) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // QUIC 使用自签证书,客户端需要跳过验证 + var transOpts *tunnel.TransportOptions + if transport == tunnel.TransportQUIC { + transOpts = &tunnel.TransportOptions{Insecure: true} + } + + backendAddr := fmt.Sprintf("127.0.0.1:%d", basePort+81) + gatewayAddr := fmt.Sprintf("127.0.0.1:%d", basePort) + proxyAddr := fmt.Sprintf("http://127.0.0.1:%d", basePort+80) + + mux := http.NewServeMux() + mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(map[string]string{"transport": transport}); err != nil { + t.Logf("encode response failed: %v", err) + } + }) + backendSrv := &http.Server{Addr: backendAddr, Handler: mux} + go func() { + if err := backendSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("backend server error: %v", err) + } + }() + t.Cleanup(func() { + if err := backendSrv.Shutdown(ctx); err != nil { + t.Logf("backend shutdown failed: %v", err) + } + }) + time.Sleep(100 * time.Millisecond) + + gw := tunnelgateway.New(&tunnelgateway.Config{ + ListenAddr: gatewayAddr, + Transport: transport, + TransportOptions: transOpts, + HTTPPort: basePort + 80, + }) + if err := gw.Start(ctx); err != nil { + t.Fatalf("[%s] Gateway: %v", transport, err) + } + t.Cleanup(func() { + if err := gw.Stop(ctx); err != nil { + t.Logf("[%s] Gateway stop failed: %v", transport, err) + } + }) + + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: gatewayAddr, + Transport: transport, + TransportOptions: transOpts, + ServiceName: transport + "-svc", + Endpoints: []tunnel.EndpointConfig{{Type: "http", LocalAddr: backendAddr}}, + }) + if err := agent.Start(ctx); err != nil { + t.Fatalf("[%s] Agent: %v", transport, err) + } + t.Cleanup(func() { + if err := agent.Stop(ctx); err != nil { + t.Logf("[%s] Agent stop failed: %v", transport, err) + } + }) + time.Sleep(500 * time.Millisecond) + + if len(gw.Services()) == 0 { + t.Fatalf("[%s] No services", transport) + } + + resp, err := http.Get(proxyAddr + "/" + transport + "-svc/hello") + if err != nil { + t.Fatalf("[%s] Request: %v", transport, err) + } + t.Cleanup(func() { + if err := resp.Body.Close(); err != nil { + t.Logf("[%s] response close failed: %v", transport, err) + } + }) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("[%s] read response failed: %v", transport, err) + } + t.Logf("[%s] %s", transport, body) + + if resp.StatusCode != 200 { + t.Errorf("[%s] Status %d", transport, resp.StatusCode) + } +} + +func TestAgentProxy_MultiBackends(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for i := 1; i <= 3; i++ { + port := 21080 + i + name := fmt.Sprintf("b%d", i) + mux := http.NewServeMux() + mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(map[string]string{"backend": name}); err != nil { + t.Logf("encode response failed: %v", err) + } + }) + srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: mux} + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("backend server error: %v", err) + } + }() + t.Cleanup(func() { + if err := srv.Shutdown(ctx); err != nil { + t.Logf("backend shutdown failed: %v", err) + } + }) + } + time.Sleep(100 * time.Millisecond) + + gw := tunnelgateway.New(&tunnelgateway.Config{ + ListenAddr: "127.0.0.1:21000", + Transport: "yamux", + HTTPPort: 21080, + }) + if err := gw.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gw.Stop(ctx); err != nil { + t.Logf("gateway stop failed: %v", err) + } + }) + + var agents []*tunnelagent.Agent + for i := 1; i <= 3; i++ { + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: "127.0.0.1:21000", + Transport: "yamux", + ServiceName: fmt.Sprintf("svc-%d", i), + Endpoints: []tunnel.EndpointConfig{{Type: "http", LocalAddr: fmt.Sprintf("127.0.0.1:%d", 21080+i)}}, + }) + if err := agent.Start(ctx); err != nil { + t.Fatal(err) + } + agents = append(agents, agent) + } + defer func() { + for _, a := range agents { + if err := a.Stop(ctx); err != nil { + t.Logf("agent stop failed: %v", err) + } + } + }() + time.Sleep(500 * time.Millisecond) + + for i := 1; i <= 3; i++ { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:21080/svc-%d/api", i)) + if err != nil { + t.Errorf("svc-%d: %v", i, err) + continue + } + if err := resp.Body.Close(); err != nil { + t.Errorf("svc-%d: close failed: %v", i, err) + } + if resp.StatusCode != 200 { + t.Errorf("svc-%d: status %d", i, resp.StatusCode) + } + } +} + +func TestAgentProxy_POST(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + mux := http.NewServeMux() + mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Logf("read request failed: %v", err) + return + } + if err := json.NewEncoder(w).Encode(map[string]any{"method": r.Method, "body": string(body)}); err != nil { + t.Logf("encode response failed: %v", err) + } + }) + srv := &http.Server{Addr: "127.0.0.1:22081", Handler: mux} + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("backend server error: %v", err) + } + }() + t.Cleanup(func() { + if err := srv.Shutdown(ctx); err != nil { + t.Logf("backend shutdown failed: %v", err) + } + }) + time.Sleep(100 * time.Millisecond) + + gw := tunnelgateway.New(&tunnelgateway.Config{ListenAddr: "127.0.0.1:22000", Transport: "yamux", HTTPPort: 22080}) + if err := gw.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gw.Stop(ctx); err != nil { + t.Logf("gateway stop failed: %v", err) + } + }) + + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: "127.0.0.1:22000", + Transport: "yamux", + ServiceName: "post-svc", + Endpoints: []tunnel.EndpointConfig{{Type: "http", LocalAddr: "127.0.0.1:22081"}}, + }) + if err := agent.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := agent.Stop(ctx); err != nil { + t.Logf("agent stop failed: %v", err) + } + }) + time.Sleep(500 * time.Millisecond) + + resp, err := http.Post("http://127.0.0.1:22080/post-svc/echo", "application/json", strings.NewReader(`{"test":"data"}`)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := resp.Body.Close(); err != nil { + t.Logf("response close failed: %v", err) + } + }) + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode response failed: %v", err) + } + if result["method"] != "POST" { + t.Errorf("method=%v", result["method"]) + } +} + +func TestAgentProxy_LargeBody(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + mux := http.NewServeMux() + mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Logf("read request failed: %v", err) + return + } + if _, err := w.Write(body); err != nil { + t.Logf("write response failed: %v", err) + } + }) + srv := &http.Server{Addr: "127.0.0.1:23081", Handler: mux} + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("backend server error: %v", err) + } + }() + t.Cleanup(func() { + if err := srv.Shutdown(ctx); err != nil { + t.Logf("backend shutdown failed: %v", err) + } + }) + time.Sleep(100 * time.Millisecond) + + gw := tunnelgateway.New(&tunnelgateway.Config{ListenAddr: "127.0.0.1:23000", Transport: "yamux", HTTPPort: 23080}) + if err := gw.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gw.Stop(ctx); err != nil { + t.Logf("gateway stop failed: %v", err) + } + }) + + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: "127.0.0.1:23000", + Transport: "yamux", + ServiceName: "upload-svc", + Endpoints: []tunnel.EndpointConfig{{Type: "http", LocalAddr: "127.0.0.1:23081"}}, + }) + if err := agent.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := agent.Stop(ctx); err != nil { + t.Logf("agent stop failed: %v", err) + } + }) + time.Sleep(500 * time.Millisecond) + + for _, size := range []int{1024, 10 * 1024, 100 * 1024} { + t.Run(fmt.Sprintf("%dKB", size/1024), func(t *testing.T) { + data := make([]byte, size) + for i := range data { + data[i] = byte(i % 256) + } + resp, err := http.Post("http://127.0.0.1:23080/upload-svc/upload", "application/octet-stream", bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := resp.Body.Close(); err != nil { + t.Logf("response close failed: %v", err) + } + }) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response failed: %v", err) + } + if !bytes.Equal(data, body) { + t.Error("body mismatch") + } + }) + } +} + +func TestAgentProxy_Concurrent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + var count int32 + var mu sync.Mutex + mux := http.NewServeMux() + mux.HandleFunc("/count", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + count++ + mu.Unlock() + time.Sleep(10 * time.Millisecond) + if _, err := w.Write([]byte("ok")); err != nil { + t.Logf("write response failed: %v", err) + } + }) + srv := &http.Server{Addr: "127.0.0.1:24081", Handler: mux} + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("backend server error: %v", err) + } + }() + t.Cleanup(func() { + if err := srv.Shutdown(ctx); err != nil { + t.Logf("backend shutdown failed: %v", err) + } + }) + time.Sleep(100 * time.Millisecond) + + gw := tunnelgateway.New(&tunnelgateway.Config{ListenAddr: "127.0.0.1:24000", Transport: "yamux", HTTPPort: 24080}) + if err := gw.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gw.Stop(ctx); err != nil { + t.Logf("gateway stop failed: %v", err) + } + }) + + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: "127.0.0.1:24000", + Transport: "yamux", + ServiceName: "concurrent-svc", + Endpoints: []tunnel.EndpointConfig{{Type: "http", LocalAddr: "127.0.0.1:24081"}}, + }) + if err := agent.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := agent.Stop(ctx); err != nil { + t.Logf("agent stop failed: %v", err) + } + }) + time.Sleep(500 * time.Millisecond) + + n := 50 + var wg sync.WaitGroup + errs := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + resp, err := http.Get("http://127.0.0.1:24080/concurrent-svc/count") + if err != nil { + errs <- err + return + } + if err := resp.Body.Close(); err != nil { + errs <- err + } + }() + } + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } + mu.Lock() + t.Logf("Total: %d", count) + mu.Unlock() +} + +func TestAgentProxy_TCP(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ln, err := net.Listen("tcp", "127.0.0.1:26081") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := ln.Close(); err != nil { + t.Logf("listener close failed: %v", err) + } + }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { + if err := c.Close(); err != nil { + t.Logf("conn close failed: %v", err) + } + }() + if _, err := io.Copy(c, c); err != nil { + t.Logf("echo copy failed: %v", err) + } + }(conn) + } + }() + + gw := tunnelgateway.New(&tunnelgateway.Config{ListenAddr: "127.0.0.1:26000", Transport: "yamux", HTTPPort: 26080}) + if err := gw.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gw.Stop(ctx); err != nil { + t.Logf("gateway stop failed: %v", err) + } + }) + + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: "127.0.0.1:26000", + Transport: "yamux", + ServiceName: "tcp-svc", + Endpoints: []tunnel.EndpointConfig{{Type: "http", LocalAddr: "127.0.0.1:26081"}}, + }) + if err := agent.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := agent.Stop(ctx); err != nil { + t.Logf("agent stop failed: %v", err) + } + }) + time.Sleep(500 * time.Millisecond) + + if len(gw.Services()) == 0 { + t.Fatal("no services") + } + t.Logf("services: %d", len(gw.Services())) +} + +func TestMultipleAgents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + gw := tunnelgateway.New(&tunnelgateway.Config{ListenAddr: "127.0.0.1:27000", Transport: "yamux", HTTPPort: 27080}) + if err := gw.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gw.Stop(ctx); err != nil { + t.Logf("gateway stop failed: %v", err) + } + }) + + n := 5 + var agents []*tunnelagent.Agent + var servers []*http.Server + + for i := 0; i < n; i++ { + port := 27081 + i + name := fmt.Sprintf("multi-svc-%d", i) + mux := http.NewServeMux() + mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(map[string]string{"svc": name}); err != nil { + t.Logf("encode response failed: %v", err) + } + }) + srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: mux} + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("backend server error: %v", err) + } + }() + servers = append(servers, srv) + + agent := tunnelagent.New(&tunnelagent.Config{ + GatewayAddr: "127.0.0.1:27000", + Transport: "yamux", + ServiceName: name, + Endpoints: []tunnel.EndpointConfig{{Type: "http", LocalAddr: fmt.Sprintf("127.0.0.1:%d", port)}}, + }) + if err := agent.Start(ctx); err != nil { + t.Fatal(err) + } + agents = append(agents, agent) + } + defer func() { + for _, a := range agents { + if err := a.Stop(ctx); err != nil { + t.Logf("agent stop failed: %v", err) + } + } + for _, s := range servers { + if err := s.Shutdown(ctx); err != nil { + t.Logf("server shutdown failed: %v", err) + } + } + }() + time.Sleep(1 * time.Second) + + if len(gw.Services()) != n { + t.Errorf("expected %d services, got %d", n, len(gw.Services())) + } + t.Logf("services: %d", len(gw.Services())) + + for i := 0; i < n; i++ { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:27080/multi-svc-%d/info", i)) + if err != nil { + t.Errorf("svc %d: %v", i, err) + continue + } + if resp != nil { + if err := resp.Body.Close(); err != nil { + t.Errorf("svc %d: close failed: %v", i, err) + } + if resp.StatusCode != 200 { + t.Errorf("svc %d: %d", i, resp.StatusCode) + } + } + } +} diff --git a/core/tunnel/kcp/kcp.go b/core/tunnel/kcp/kcp.go new file mode 100644 index 000000000..56ec690b7 --- /dev/null +++ b/core/tunnel/kcp/kcp.go @@ -0,0 +1,246 @@ +package kcp + +import ( + "context" + "crypto/tls" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/xtaci/kcp-go/v5" + "github.com/xtaci/smux" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +func init() { + tunnel.RegisterTransport(tunnel.TransportKCP, NewTransport) +} + +// NewTransport creates a new KCP transport +func NewTransport(opts *tunnel.TransportOptions) (tunnel.Transport, error) { + return &kcpTransport{opts: opts}, nil +} + +type kcpTransport struct { + opts *tunnel.TransportOptions +} + +func (t *kcpTransport) Name() string { + return tunnel.TransportKCP +} + +func (t *kcpTransport) Dial(ctx context.Context, addr string) (tunnel.Session, error) { + // 创建 KCP 连接 + conn, err := kcp.DialWithOptions(addr, nil, 10, 3) + if err != nil { + return nil, err + } + + // 配置 KCP 参数 + t.configureKCPConn(conn) + + var netConn net.Conn = conn + if t.opts != nil && t.opts.EnableTLS { + tlsConfig := &tls.Config{InsecureSkipVerify: t.opts.Insecure} + netConn = tls.Client(conn, tlsConfig) + } + + // 使用 smux 进行多路复用 + smuxConfig := t.buildSmuxConfig() + session, err := smux.Client(netConn, smuxConfig) + if err != nil { + if closeErr := conn.Close(); closeErr != nil { + return nil, closeErr + } + return nil, err + } + + return &kcpSession{ + session: session, + conn: conn, + tlsConn: netConn, + transport: t, + }, nil +} + +func (t *kcpTransport) Listen(ctx context.Context, addr string) (tunnel.Listener, error) { + ln, err := kcp.ListenWithOptions(addr, nil, 10, 3) + if err != nil { + return nil, err + } + + return &kcpListener{ + listener: ln, + transport: t, + ctx: ctx, + }, nil +} + +func (t *kcpTransport) configureKCPConn(conn *kcp.UDPSession) { + // 设置 KCP 模式:快速模式 + // nodelay: 0 关闭, 1 开启 + // interval: 内部更新间隔 ms + // resend: 快速重传模式,0 关闭,可以设置为 2(2 次 ACK 跨越将会直接重传) + // nc: 是否关闭流控,0 不关闭,1 关闭 + conn.SetNoDelay(1, 10, 2, 1) + + // 设置窗口大小 + conn.SetWindowSize(256, 256) + + // 设置读写缓冲区 + if err := conn.SetReadBuffer(4 * 1024 * 1024); err != nil { + _ = err // ignore non-fatal buffer errors + } + if err := conn.SetWriteBuffer(4 * 1024 * 1024); err != nil { + _ = err // ignore non-fatal buffer errors + } + + // 设置 MTU + conn.SetMtu(1350) + + // 设置 ACK 无延迟 + conn.SetACKNoDelay(true) +} + +func (t *kcpTransport) buildSmuxConfig() *smux.Config { + cfg := smux.DefaultConfig() + if t.opts != nil { + if t.opts.MaxStreams > 0 { + cfg.MaxReceiveBuffer = t.opts.MaxStreams * 65536 + cfg.MaxStreamBuffer = 65536 + } + if t.opts.KeepAliveInterval > 0 { + cfg.KeepAliveInterval = time.Duration(t.opts.KeepAliveInterval) * time.Second + } + } + return cfg +} + +type kcpListener struct { + listener *kcp.Listener + transport *kcpTransport + ctx context.Context +} + +func (l *kcpListener) Accept() (tunnel.Session, error) { + conn, err := l.listener.AcceptKCP() + if err != nil { + return nil, err + } + + // 配置 KCP 参数 + l.transport.configureKCPConn(conn) + + var netConn net.Conn = conn + if l.transport.opts != nil && l.transport.opts.EnableTLS { + cert, err := tls.LoadX509KeyPair(l.transport.opts.CertFile, l.transport.opts.KeyFile) + if err != nil { + if closeErr := conn.Close(); closeErr != nil { + return nil, closeErr + } + return nil, err + } + netConn = tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) + } + + // 使用 smux 进行多路复用 + smuxConfig := l.transport.buildSmuxConfig() + session, err := smux.Server(netConn, smuxConfig) + if err != nil { + if closeErr := conn.Close(); closeErr != nil { + return nil, closeErr + } + return nil, err + } + + return &kcpSession{ + session: session, + conn: conn, + tlsConn: netConn, + transport: l.transport, + }, nil +} + +func (l *kcpListener) Close() error { return l.listener.Close() } +func (l *kcpListener) Addr() net.Addr { return l.listener.Addr() } + +type kcpSession struct { + session *smux.Session + conn *kcp.UDPSession + tlsConn net.Conn + transport *kcpTransport + mu sync.Mutex + closed atomic.Bool +} + +func (s *kcpSession) Open(ctx context.Context) (tunnel.Stream, error) { + stream, err := s.session.OpenStream() + if err != nil { + return nil, err + } + return &kcpStream{stream: stream, session: s}, nil +} + +// OpenWithPriority 打开指定优先级的流(1-10,1最高) +func (s *kcpSession) OpenWithPriority(ctx context.Context, priority int) (tunnel.Stream, error) { + // kcp 不支持优先级,直接调用 Open + return s.Open(ctx) +} + +func (s *kcpSession) Accept() (tunnel.Stream, error) { + stream, err := s.session.AcceptStream() + if err != nil { + return nil, err + } + return &kcpStream{stream: stream, session: s}, nil +} + +func (s *kcpSession) Close() error { + if s.closed.Swap(true) { + return nil + } + if err := s.session.Close(); err != nil { + return err + } + return s.conn.Close() +} + +func (s *kcpSession) IsClosed() bool { + return s.session.IsClosed() +} + +func (s *kcpSession) NumStreams() int { + return s.session.NumStreams() +} + +func (s *kcpSession) LocalAddr() net.Addr { return s.conn.LocalAddr() } +func (s *kcpSession) RemoteAddr() net.Addr { return s.conn.RemoteAddr() } + +type kcpStream struct { + stream *smux.Stream + session *kcpSession +} + +func (s *kcpStream) Read(p []byte) (int, error) { return s.stream.Read(p) } +func (s *kcpStream) Write(p []byte) (int, error) { return s.stream.Write(p) } +func (s *kcpStream) Close() error { return s.stream.Close() } +func (s *kcpStream) LocalAddr() net.Addr { return s.session.conn.LocalAddr() } +func (s *kcpStream) RemoteAddr() net.Addr { return s.session.conn.RemoteAddr() } + +func (s *kcpStream) SetDeadline(t time.Time) error { + if err := s.stream.SetReadDeadline(t); err != nil { + return err + } + return s.stream.SetWriteDeadline(t) +} + +func (s *kcpStream) SetReadDeadline(t time.Time) error { return s.stream.SetReadDeadline(t) } +func (s *kcpStream) SetWriteDeadline(t time.Time) error { return s.stream.SetWriteDeadline(t) } + +// Priority 获取流优先级 +func (s *kcpStream) Priority() int { + // kcp 不支持优先级,返回默认值 + return 5 +} diff --git a/core/tunnel/quic/quic.go b/core/tunnel/quic/quic.go new file mode 100644 index 000000000..dcae0a610 --- /dev/null +++ b/core/tunnel/quic/quic.go @@ -0,0 +1,241 @@ +package quic + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "sync" + "time" + + "github.com/quic-go/quic-go" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +func init() { + tunnel.RegisterTransport(tunnel.TransportQUIC, NewTransport) +} + +// NewTransport creates a new QUIC transport +func NewTransport(opts *tunnel.TransportOptions) (tunnel.Transport, error) { + return &quicTransport{opts: opts}, nil +} + +type quicTransport struct { + opts *tunnel.TransportOptions +} + +func (t *quicTransport) Name() string { + return tunnel.TransportQUIC +} + +func (t *quicTransport) Dial(ctx context.Context, addr string) (tunnel.Session, error) { + tlsConfig := t.buildClientTLSConfig() + + quicConfig := t.buildQUICConfig() + conn, err := quic.DialAddr(ctx, addr, tlsConfig, quicConfig) + if err != nil { + return nil, err + } + + return &quicSession{conn: conn}, nil +} + +func (t *quicTransport) Listen(ctx context.Context, addr string) (tunnel.Listener, error) { + tlsConfig, err := t.buildServerTLSConfig() + if err != nil { + return nil, err + } + + quicConfig := t.buildQUICConfig() + ln, err := quic.ListenAddr(addr, tlsConfig, quicConfig) + if err != nil { + return nil, err + } + + return &quicListener{listener: ln, ctx: ctx}, nil +} + +func (t *quicTransport) buildClientTLSConfig() *tls.Config { + tlsConfig := &tls.Config{ + NextProtos: []string{"lava-tunnel"}, + } + if t.opts != nil { + tlsConfig.InsecureSkipVerify = t.opts.Insecure + } + return tlsConfig +} + +func (t *quicTransport) buildServerTLSConfig() (*tls.Config, error) { + if t.opts == nil || t.opts.CertFile == "" || t.opts.KeyFile == "" { + // 使用自签名证书进行开发/测试 + return generateSelfSignedTLSConfig() + } + + cert, err := tls.LoadX509KeyPair(t.opts.CertFile, t.opts.KeyFile) + if err != nil { + return nil, err + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"lava-tunnel"}, + }, nil +} + +func (t *quicTransport) buildQUICConfig() *quic.Config { + cfg := &quic.Config{ + MaxIdleTimeout: 30 * time.Second, + KeepAlivePeriod: 15 * time.Second, + } + if t.opts != nil { + if t.opts.MaxStreams > 0 { + cfg.MaxIncomingStreams = int64(t.opts.MaxStreams) + cfg.MaxIncomingUniStreams = int64(t.opts.MaxStreams) + } + if t.opts.KeepAliveInterval > 0 { + cfg.KeepAlivePeriod = time.Duration(t.opts.KeepAliveInterval) * time.Second + } + } + return cfg +} + +// generateSelfSignedTLSConfig 生成自签名 TLS 配置(仅用于开发/测试) +func generateSelfSignedTLSConfig() (*tls.Config, error) { + cert, err := generateSelfSignedCert() + if err != nil { + return nil, err + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"lava-tunnel"}, + }, nil +} + +// generateSelfSignedCert 生成自签名证书 +func generateSelfSignedCert() (tls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Lava Tunnel"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, err + } + + return tls.Certificate{ + Certificate: [][]byte{derBytes}, + PrivateKey: priv, + }, nil +} + +type quicListener struct { + listener *quic.Listener + ctx context.Context +} + +func (l *quicListener) Accept() (tunnel.Session, error) { + conn, err := l.listener.Accept(l.ctx) + if err != nil { + return nil, err + } + return &quicSession{conn: conn}, nil +} + +func (l *quicListener) Close() error { return l.listener.Close() } +func (l *quicListener) Addr() net.Addr { return l.listener.Addr() } + +type quicSession struct { + conn *quic.Conn + mu sync.Mutex +} + +func (s *quicSession) Open(ctx context.Context) (tunnel.Stream, error) { + stream, err := s.conn.OpenStreamSync(ctx) + if err != nil { + return nil, err + } + return &quicStream{stream: stream, conn: s.conn}, nil +} + +// OpenWithPriority 打开指定优先级的流(1-10,1最高) +func (s *quicSession) OpenWithPriority(ctx context.Context, priority int) (tunnel.Stream, error) { + // QUIC 不支持优先级,直接调用 Open + return s.Open(ctx) +} + +func (s *quicSession) Accept() (tunnel.Stream, error) { + stream, err := s.conn.AcceptStream(context.Background()) + if err != nil { + return nil, err + } + return &quicStream{stream: stream, conn: s.conn}, nil +} + +func (s *quicSession) Close() error { + return s.conn.CloseWithError(0, "closed") +} + +func (s *quicSession) IsClosed() bool { + select { + case <-s.conn.Context().Done(): + return true + default: + return false + } +} + +func (s *quicSession) NumStreams() int { + // QUIC 不直接暴露活跃流数量,返回 -1 表示未知 + return -1 +} + +func (s *quicSession) LocalAddr() net.Addr { return s.conn.LocalAddr() } +func (s *quicSession) RemoteAddr() net.Addr { return s.conn.RemoteAddr() } + +type quicStream struct { + stream *quic.Stream + conn *quic.Conn +} + +func (s *quicStream) Read(p []byte) (int, error) { return s.stream.Read(p) } +func (s *quicStream) Write(p []byte) (int, error) { return s.stream.Write(p) } +func (s *quicStream) Close() error { return s.stream.Close() } +func (s *quicStream) LocalAddr() net.Addr { return s.conn.LocalAddr() } +func (s *quicStream) RemoteAddr() net.Addr { return s.conn.RemoteAddr() } + +func (s *quicStream) SetDeadline(t time.Time) error { + if err := s.stream.SetReadDeadline(t); err != nil { + return err + } + return s.stream.SetWriteDeadline(t) +} + +func (s *quicStream) SetReadDeadline(t time.Time) error { return s.stream.SetReadDeadline(t) } +func (s *quicStream) SetWriteDeadline(t time.Time) error { return s.stream.SetWriteDeadline(t) } + +// Priority 获取流优先级 +func (s *quicStream) Priority() int { + // QUIC 不支持优先级,返回默认值 + return 5 +} diff --git a/core/tunnel/transport.go b/core/tunnel/transport.go new file mode 100644 index 000000000..11fb03958 --- /dev/null +++ b/core/tunnel/transport.go @@ -0,0 +1,102 @@ +package tunnel + +import ( + "context" + "fmt" + "sync" +) + +// 支持的传输协议常量 +const ( + // TransportYamux yamux 传输协议 + TransportYamux = "yamux" + // TransportQUIC QUIC 传输协议 + TransportQUIC = "quic" + // TransportKCP KCP 传输协议 + TransportKCP = "kcp" +) + +var ( + transportMu sync.RWMutex + transportFactories = make(map[string]TransportFactory) +) + +// TransportFactory 传输层工厂函数 +type TransportFactory func(opts *TransportOptions) (Transport, error) + +// RegisterTransport 注册传输层工厂 +func RegisterTransport(name string, factory TransportFactory) { + transportMu.Lock() + defer transportMu.Unlock() + if factory == nil { + panic("tunnel: RegisterTransport factory is nil") + } + if _, dup := transportFactories[name]; dup { + panic("tunnel: RegisterTransport called twice for factory " + name) + } + transportFactories[name] = factory +} + +// NewTransport 创建传输层实例 +func NewTransport(name string, opts *TransportOptions) (Transport, error) { + transportMu.RLock() + factory, ok := transportFactories[name] + transportMu.RUnlock() + + if !ok { + return nil, fmt.Errorf("%w: %s", ErrTransportNotSupported, name) + } + + if opts == nil { + opts = DefaultTransportOptions() + } + + return factory(opts) +} + +// GetTransportFactory 获取已注册的传输层工厂 +func GetTransportFactory(name string) (TransportFactory, bool) { + transportMu.RLock() + defer transportMu.RUnlock() + f, ok := transportFactories[name] + return f, ok +} + +// ListTransports 列出所有已注册的传输层 +func ListTransports() []string { + transportMu.RLock() + defer transportMu.RUnlock() + + names := make([]string, 0, len(transportFactories)) + for name := range transportFactories { + names = append(names, name) + } + return names +} + +// MustNewTransport 创建传输层实例,失败时 panic +func MustNewTransport(name string, opts *TransportOptions) Transport { + t, err := NewTransport(name, opts) + if err != nil { + panic(err) + } + return t +} + +// DialTransport 使用指定传输协议连接到服务端 +func DialTransport(ctx context.Context, transportName, addr string, opts *TransportOptions) (Session, error) { + t, err := NewTransport(transportName, opts) + if err != nil { + return nil, err + } + return t.Dial(ctx, addr) +} + +// ListenTransport 使用指定传输协议监听 +func ListenTransport(ctx context.Context, transportName, addr string, opts *TransportOptions) (Listener, error) { + t, err := NewTransport(transportName, opts) + if err != nil { + return nil, err + } + return t.Listen(ctx, addr) +} diff --git a/core/tunnel/tunnelagent/agent.go b/core/tunnel/tunnelagent/agent.go new file mode 100644 index 000000000..9b70acb6e --- /dev/null +++ b/core/tunnel/tunnelagent/agent.go @@ -0,0 +1,249 @@ +// Package tunnelagent 提供隧道代理客户端实现 +// +// 这个包实现了 tunnel.Agent 接口,负责将本地服务注册到代理网关。 +// +// 示例用法: +// +// agent := tunnelagent.New(&tunnelagent.Config{ +// GatewayAddr: "localhost:8080", +// Transport: "yamux", +// ServiceName: "my-service", +// Endpoints: []tunnelagent.EndpointConfig{ +// {Type: "http", LocalAddr: ":8081"}, +// {Type: "debug", LocalAddr: ":6060"}, +// }, +// }) +// +// if err := agent.Start(ctx); err != nil { +// log.Fatal(err) +// } +// defer agent.Stop(ctx) +// +// 或使用 Builder 模式: +// +// agent, err := tunnelagent.NewBuilder(). +// WithGatewayAddr("localhost:8080"). +// WithServiceName("my-service"). +// AddEndpoint("http", ":8081", "/"). +// AddEndpoint("debug", ":6060", "/debug"). +// Build() +package tunnelagent + +import ( + "context" + "fmt" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +// Config 是 Agent 的配置 +type Config = tunnel.AgentConfig + +// EndpointConfig 端点配置 +type EndpointConfig = tunnel.EndpointConfig + +// TransportOptions 传输层选项 +type TransportOptions = tunnel.TransportOptions + +// TLSConfig TLS 配置 +type TLSConfig = tunnel.TLSConfig + +// ServiceInfo 服务信息 +type ServiceInfo = tunnel.ServiceInfo + +// Endpoint 端点信息 +type Endpoint = tunnel.Endpoint + +// Status 代理状态 +type Status = tunnel.AgentStatus + +// Info Agent 信息 +type Info = tunnel.AgentInfo + +// 状态常量 +const ( + StatusDisconnected = tunnel.StatusDisconnected + StatusConnecting = tunnel.StatusConnecting + StatusConnected = tunnel.StatusConnected + StatusReconnecting = tunnel.StatusReconnecting +) + +// 传输协议常量 +const ( + TransportYamux = tunnel.TransportYamux + TransportQUIC = tunnel.TransportQUIC + TransportKCP = tunnel.TransportKCP +) + +// Agent 是隧道代理客户端的封装 +type Agent struct { + inner tunnel.Agent + cfg *Config +} + +// New 创建一个新的 Agent 实例 +func New(cfg *Config) *Agent { + return &Agent{ + inner: NewAgent(cfg), + cfg: cfg, + } +} + +// Start 启动 Agent,连接到 Gateway +func (a *Agent) Start(ctx context.Context) error { + return a.inner.Start(ctx) +} + +// Stop 停止 Agent +func (a *Agent) Stop(ctx context.Context) error { + return a.inner.Stop(ctx) +} + +// Register 注册服务到 Gateway +func (a *Agent) Register(ctx context.Context, service *ServiceInfo) error { + return a.inner.Register(ctx, service) +} + +// Deregister 从 Gateway 注销服务 +func (a *Agent) Deregister(ctx context.Context, serviceID string) error { + return a.inner.Deregister(ctx, serviceID) +} + +// Status 获取 Agent 当前状态 +func (a *Agent) Status() Status { + return a.inner.Status() +} + +// Info 获取 Agent 信息 +func (a *Agent) Info() *Info { + return a.inner.Info() +} + +// Config 获取 Agent 配置 +func (a *Agent) Config() *Config { + return a.cfg +} + +// Inner 获取底层的 tunnel.Agent 实现 +func (a *Agent) Inner() tunnel.Agent { + return a.inner +} + +// Builder Agent 构建器 +type Builder struct { + cfg *Config +} + +// NewBuilder 创建 Agent 构建器 +func NewBuilder() *Builder { + cfg := tunnel.DefaultAgentConfig() + return &Builder{cfg: &cfg} +} + +// WithGatewayAddr 设置网关地址 +func (b *Builder) WithGatewayAddr(addr string) *Builder { + b.cfg.GatewayAddr = addr + return b +} + +// WithTransport 设置传输协议 +func (b *Builder) WithTransport(transport string) *Builder { + b.cfg.Transport = transport + return b +} + +// WithTransportOptions 设置传输层选项 +func (b *Builder) WithTransportOptions(opts *TransportOptions) *Builder { + b.cfg.TransportOptions = opts + return b +} + +// WithServiceID 设置服务 ID +func (b *Builder) WithServiceID(id string) *Builder { + b.cfg.ServiceID = id + return b +} + +// WithServiceName 设置服务名称 +func (b *Builder) WithServiceName(name string) *Builder { + b.cfg.ServiceName = name + return b +} + +// WithServiceVersion 设置服务版本 +func (b *Builder) WithServiceVersion(version string) *Builder { + b.cfg.ServiceVersion = version + return b +} + +// WithMetadata 设置元数据 +func (b *Builder) WithMetadata(metadata map[string]string) *Builder { + b.cfg.Metadata = metadata + return b +} + +// WithEndpoints 设置端点配置 +func (b *Builder) WithEndpoints(endpoints []EndpointConfig) *Builder { + b.cfg.Endpoints = endpoints + return b +} + +// AddEndpoint 添加端点 +func (b *Builder) AddEndpoint(endpointType, localAddr, path string) *Builder { + b.cfg.Endpoints = append(b.cfg.Endpoints, EndpointConfig{ + Type: endpointType, + LocalAddr: localAddr, + Path: path, + }) + return b +} + +// WithHeartbeatInterval 设置心跳间隔(秒) +func (b *Builder) WithHeartbeatInterval(interval int) *Builder { + b.cfg.HeartbeatInterval = interval + return b +} + +// WithReconnectInterval 设置重连间隔(秒) +func (b *Builder) WithReconnectInterval(interval int) *Builder { + b.cfg.ReconnectInterval = interval + return b +} + +// WithMaxReconnectAttempts 设置最大重连次数 +func (b *Builder) WithMaxReconnectAttempts(attempts int) *Builder { + b.cfg.MaxReconnectAttempts = attempts + return b +} + +// WithTLS 设置 TLS 配置 +func (b *Builder) WithTLS(tls TLSConfig) *Builder { + b.cfg.TLS = tls + return b +} + +// WithConfig 使用完整配置 +func (b *Builder) WithConfig(cfg *Config) *Builder { + b.cfg = cfg + return b +} + +// Build 构建 Agent +func (b *Builder) Build() (*Agent, error) { + if b.cfg.GatewayAddr == "" { + return nil, fmt.Errorf("tunnelagent: gateway address is required") + } + if b.cfg.Transport == "" { + b.cfg.Transport = TransportYamux + } + return New(b.cfg), nil +} + +// MustBuild 构建 Agent,失败时 panic +func (b *Builder) MustBuild() *Agent { + agent, err := b.Build() + if err != nil { + panic(err) + } + return agent +} diff --git a/core/tunnel/tunnelagent/doc.go b/core/tunnel/tunnelagent/doc.go new file mode 100644 index 000000000..18c7b6579 --- /dev/null +++ b/core/tunnel/tunnelagent/doc.go @@ -0,0 +1,89 @@ +// Package tunnelagent 提供隧道代理客户端的便捷封装 +// +// 这个包是 tunnel.Agent 的包装器,让用户可以更方便地创建和管理隧道代理客户端。 +// +// # 基本用法 +// +// 创建一个连接到 Gateway 的 Agent: +// +// agent := tunnelagent.New(&tunnelagent.Config{ +// GatewayAddr: "gateway.example.com:9000", +// Transport: "yamux", // 或 "quic", "kcp" +// ServiceName: "my-service", +// ServiceVersion: "v1.0.0", +// Endpoints: []tunnelagent.EndpointConfig{ +// {Type: "http", LocalAddr: ":8080"}, +// {Type: "grpc", LocalAddr: ":9090"}, +// {Type: "debug", LocalAddr: ":6060"}, +// }, +// }) +// +// ctx := context.Background() +// if err := agent.Start(ctx); err != nil { +// log.Fatal(err) +// } +// defer agent.Stop(ctx) +// +// # 动态服务注册 +// +// 可以在运行时动态注册和注销服务: +// +// // 注册新服务 +// err := agent.Register(ctx, &tunnelagent.ServiceInfo{ +// Name: "new-service", +// Version: "v1.0.0", +// Endpoints: []tunnelagent.Endpoint{ +// {Type: "http", Address: ":8081"}, +// }, +// }) +// +// // 注销服务 +// err = agent.Deregister(ctx, "new-service") +// +// # 传输协议 +// +// 支持多种传输协议: +// +// - yamux: 基于 TCP 的多路复用(默认) +// - quic: 基于 UDP 的 QUIC 协议 +// - kcp: 基于 UDP 的 KCP 协议 +// +// 选择传输协议时需要考虑: +// +// - yamux: 适合大多数场景,稳定可靠 +// - quic: 适合高延迟或丢包网络,支持 0-RTT +// - kcp: 适合对延迟敏感但可接受较高带宽的场景 +// +// # TLS 配置 +// +// 通过 TransportOptions 配置 TLS: +// +// agent := tunnelagent.New(&tunnelagent.Config{ +// GatewayAddr: "gateway.example.com:9000", +// Transport: "yamux", +// TransportOptions: &tunnelagent.TransportOptions{ +// EnableTLS: true, +// CertFile: "/path/to/client.crt", +// KeyFile: "/path/to/client.key", +// CAFile: "/path/to/ca.crt", +// }, +// ServiceName: "my-service", +// }) +// +// # 状态监控 +// +// 检查 Agent 状态: +// +// status := agent.Status() +// switch status { +// case tunnelagent.StatusConnected: +// fmt.Println("已连接到 Gateway") +// case tunnelagent.StatusReconnecting: +// fmt.Println("正在重连...") +// case tunnelagent.StatusDisconnected: +// fmt.Println("未连接") +// } +// +// info := agent.Info() +// fmt.Printf("Gateway: %s, Service: %s\n", info.GatewayAddr, info.ServiceName) +package tunnelagent diff --git a/core/tunnel/tunnelagent/impl.go b/core/tunnel/tunnelagent/impl.go new file mode 100644 index 000000000..05f0dede2 --- /dev/null +++ b/core/tunnel/tunnelagent/impl.go @@ -0,0 +1,925 @@ +package tunnelagent + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httputil" + "sync" + "sync/atomic" + "time" + + "github.com/pubgo/funk/v2/log" + "google.golang.org/grpc" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +var _ tunnel.Agent = (*tunnelAgent)(nil) + +// NewAgent creates a new tunnel agent +func NewAgent(cfg *tunnel.AgentConfig) tunnel.Agent { + a := &tunnelAgent{ + cfg: cfg, + services: make(map[string]*tunnel.ServiceInfo), + status: tunnel.StatusDisconnected, + stats: &Stats{ + ServiceStats: make(map[string]*ServiceStats), + LastActivity: time.Now(), + }, + } + + // 从配置中构建初始服务信息 + if cfg.ServiceName != "" { + svc := &tunnel.ServiceInfo{ + ID: cfg.ServiceID, + Name: cfg.ServiceName, + Version: cfg.ServiceVersion, + Metadata: cfg.Metadata, + } + + // 转换 Endpoints 配置到 ServiceInfo.Endpoints + for _, ep := range cfg.Endpoints { + svc.Endpoints = append(svc.Endpoints, tunnel.Endpoint{ + Type: tunnel.EndpointType(ep.Type), + Address: ep.LocalAddr, + Path: ep.Path, + Metadata: ep.Metadata, + }) + } + + a.services[cfg.ServiceName] = svc + a.stats.ServiceStats[cfg.ServiceName] = &ServiceStats{} + } + + return a +} + +// Stats 统计信息 +type Stats struct { + Connections int64 `json:"connections"` + Streams int64 `json:"streams"` + BytesIn int64 `json:"bytes_in"` + BytesOut int64 `json:"bytes_out"` + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + ResponseTimes []time.Duration `json:"response_times"` + ServiceStats map[string]*ServiceStats `json:"service_stats"` + LastActivity time.Time `json:"last_activity"` +} + +// ServiceStats 服务统计信息 +type ServiceStats struct { + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + BytesIn int64 `json:"bytes_in"` + BytesOut int64 `json:"bytes_out"` + ResponseTimes []time.Duration `json:"response_times"` + LastRequest time.Time `json:"last_request"` +} + +type tunnelAgent struct { + cfg *tunnel.AgentConfig + transport tunnel.Transport + session tunnel.Session + services map[string]*tunnel.ServiceInfo + status tunnel.AgentStatus + stats *Stats + statsMu sync.Mutex + + mu sync.RWMutex + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup + running atomic.Bool + + // Reverse proxies for forwarding requests to local services + httpProxy *httputil.ReverseProxy + grpcConn *grpc.ClientConn +} + +func (a *tunnelAgent) Start(ctx context.Context) error { + if a.running.Load() { + return tunnel.ErrAgentAlreadyRunning + } + + // Create transport + transport, err := tunnel.NewTransport(a.cfg.Transport, a.cfg.TransportOptions) + if err != nil { + return err + } + a.transport = transport + + // Connect to gateway + if err := a.connect(ctx); err != nil { + return err + } + + a.stopCh = make(chan struct{}) + a.running.Store(true) + a.status = tunnel.StatusConnected + + // Start heartbeat + a.wg.Add(1) + go a.heartbeatLoop() + + // Start accepting streams from gateway + a.wg.Add(1) + go a.acceptLoop() + + // Start reconnection monitor + a.wg.Add(1) + go a.reconnectLoop(ctx) + + return nil +} + +func (a *tunnelAgent) Stop(ctx context.Context) error { + if !a.running.Load() { + return nil + } + + a.stopOnce.Do(func() { + close(a.stopCh) + }) + + // 先关闭 session,让所有等待的 goroutine 退出 + if a.session != nil { + if err := a.session.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close session") + } + } + + // Wait for goroutines to finish with timeout + done := make(chan struct{}) + go func() { + a.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + case <-time.After(3 * time.Second): + // 超时但继续 + log.Warn().Msg("Agent stop timeout, force closing") + } + + a.running.Store(false) + a.status = tunnel.StatusDisconnected + + return nil +} + +func (a *tunnelAgent) Register(ctx context.Context, service *tunnel.ServiceInfo) error { + a.mu.Lock() + a.services[service.Name] = service + a.mu.Unlock() + + if a.session == nil || a.session.IsClosed() { + return nil // Will register when connected + } + + return a.sendRegister(ctx, service) +} + +func (a *tunnelAgent) Deregister(ctx context.Context, serviceName string) error { + a.mu.Lock() + delete(a.services, serviceName) + a.mu.Unlock() + + if a.session == nil || a.session.IsClosed() { + return nil + } + + return a.sendDeregister(ctx, serviceName) +} + +func (a *tunnelAgent) Status() tunnel.AgentStatus { + return a.status +} + +func (a *tunnelAgent) Info() *tunnel.AgentInfo { + a.mu.RLock() + defer a.mu.RUnlock() + + info := &tunnel.AgentInfo{ + GatewayAddr: a.cfg.GatewayAddr, + ServiceName: a.cfg.ServiceName, + ServiceVersion: a.cfg.ServiceVersion, + Status: a.status.String(), + } + + // 收集端点信息 + for _, svc := range a.services { + info.Endpoints = append(info.Endpoints, svc.Endpoints...) + } + + return info +} + +func (a *tunnelAgent) connect(ctx context.Context) error { + session, err := a.transport.Dial(ctx, a.cfg.GatewayAddr) + if err != nil { + a.status = tunnel.StatusDisconnected + atomic.AddInt64(&a.stats.Errors, 1) + a.updateLastActivity() + return err + } + a.session = session + a.status = tunnel.StatusConnected + + // 更新统计信息 + atomic.AddInt64(&a.stats.Connections, 1) + a.updateLastActivity() + + // Register all services + a.mu.RLock() + services := make([]*tunnel.ServiceInfo, 0, len(a.services)) + for _, svc := range a.services { + services = append(services, svc) + // 确保服务统计信息存在 + a.getOrCreateServiceStats(svc.Name) + } + a.mu.RUnlock() + + for _, svc := range services { + if err := a.sendRegister(ctx, svc); err != nil { + log.Warn().Err(err).Str("service", svc.Name).Msg("Failed to register service") + stats := a.getOrCreateServiceStats(svc.Name) + atomic.AddInt64(&stats.Errors, 1) + } + } + + return nil +} + +func (a *tunnelAgent) sendRegister(ctx context.Context, service *tunnel.ServiceInfo) error { + // 服务注册使用高优先级 + stream, err := a.session.OpenWithPriority(ctx, 2) + if err != nil { + // 降级到普通优先级 + stream, err = a.session.Open(ctx) + if err != nil { + a.handleError(err) + return err + } + } + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close register stream") + } + }() + + payload, err := json.Marshal(service) + if err != nil { + return err + } + + msg := &tunnel.Message{ + Type: tunnel.MessageTypeRegister, + Payload: payload, + } + if err := a.sendMessage(stream, msg); err != nil { + a.handleError(err) + return err + } + return nil +} + +func (a *tunnelAgent) sendDeregister(ctx context.Context, serviceName string) error { + // 服务注销使用高优先级 + stream, err := a.session.OpenWithPriority(ctx, 2) + if err != nil { + // 降级到普通优先级 + stream, err = a.session.Open(ctx) + if err != nil { + a.handleError(err) + return err + } + } + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close deregister stream") + } + }() + + msg := &tunnel.Message{ + Type: tunnel.MessageTypeDeregister, + Payload: []byte(serviceName), + } + if err := a.sendMessage(stream, msg); err != nil { + a.handleError(err) + return err + } + return nil +} + +func (a *tunnelAgent) sendMessage(stream tunnel.Stream, msg *tunnel.Message) error { + data, err := json.Marshal(msg) + if err != nil { + atomic.AddInt64(&a.stats.Errors, 1) + a.updateLastActivity() + return err + } + + // Write length prefix (4 bytes) + data + length := uint32(len(data)) + header := []byte{ + byte(length >> 24), + byte(length >> 16), + byte(length >> 8), + byte(length), + } + + if _, err := stream.Write(header); err != nil { + atomic.AddInt64(&a.stats.Errors, 1) + a.updateLastActivity() + return err + } + if _, err := stream.Write(data); err != nil { + atomic.AddInt64(&a.stats.Errors, 1) + a.updateLastActivity() + return err + } + + // 更新统计信息 + atomic.AddInt64(&a.stats.BytesOut, int64(len(header)+len(data))) + a.updateLastActivity() + return nil +} + +func (a *tunnelAgent) heartbeatLoop() { + defer a.wg.Done() + + interval := time.Duration(a.cfg.HeartbeatInterval) * time.Second + if interval <= 0 { + interval = 30 * time.Second + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-a.stopCh: + return + case <-ticker.C: + if err := a.sendHeartbeat(); err != nil { + log.Warn().Err(err).Msg("Failed to send heartbeat") + } + } + } +} + +func (a *tunnelAgent) handleError(err error) { + switch { + case errors.Is(err, tunnel.ErrSessionClosed): + // 会话关闭,需要重连 + log.Warn().Err(err).Msg("Session closed, will reconnect") + go a.reconnectImmediately() + case errors.Is(err, tunnel.ErrConnectionFailed): + // 连接失败,需要重连 + log.Warn().Err(err).Msg("Connection failed, will reconnect") + go a.reconnectImmediately() + case errors.Is(err, tunnel.ErrTimeout): + // 超时错误,可能是网络问题,需要重连 + log.Warn().Err(err).Msg("Timeout error, will reconnect") + go a.reconnectImmediately() + default: + // 其他错误,记录但不需要重连 + log.Warn().Err(err).Msg("Unexpected error") + } +} + +func (a *tunnelAgent) reconnectImmediately() { + // 立即尝试重连,而不是等待重连计时器 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + a.status = tunnel.StatusReconnecting + if err := a.connect(ctx); err != nil { + log.Warn().Err(err).Msg("Immediate reconnect failed") + } +} + +func (a *tunnelAgent) sendHeartbeat() error { + if a.session == nil || a.session.IsClosed() { + return tunnel.ErrSessionClosed + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 心跳使用中优先级 + stream, err := a.session.OpenWithPriority(ctx, 5) + if err != nil { + // 降级到普通优先级 + stream, err = a.session.Open(ctx) + if err != nil { + a.handleError(err) + return err + } + } + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close heartbeat stream") + } + }() + + msg := &tunnel.Message{Type: tunnel.MessageTypeHeartbeat} + if err := a.sendMessage(stream, msg); err != nil { + a.handleError(err) + return err + } + return nil +} + +func (a *tunnelAgent) acceptLoop() { + defer a.wg.Done() + + log.Debug().Msg("Agent: acceptLoop started, waiting for streams from gateway") + + for { + select { + case <-a.stopCh: + log.Debug().Msg("Agent: acceptLoop stopped by stopCh") + return + default: + } + + if a.session == nil || a.session.IsClosed() { + time.Sleep(100 * time.Millisecond) + continue + } + + stream, err := a.session.Accept() + if err != nil { + if a.session.IsClosed() { + log.Debug().Msg("Agent: acceptLoop stopped, session closed") + return + } + log.Warn().Err(err).Msg("Agent: Failed to accept stream from gateway") + continue + } + + log.Debug().Msg("Agent: Accepted stream from gateway, handling...") + go a.handleStream(stream) + } +} + +func (a *tunnelAgent) handleStream(stream tunnel.Stream) { + startTime := time.Now() + log.Debug().Msg("Agent: Accepted new stream from gateway") + + // 更新流统计信息 + atomic.AddInt64(&a.stats.Streams, 1) + a.updateLastActivity() + + // Read message header + header := make([]byte, 4) + if _, err := io.ReadFull(stream, header); err != nil { + log.Warn().Err(err).Msg("Agent: Failed to read message header") + atomic.AddInt64(&a.stats.Errors, 1) + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close stream after header read error") + } + return + } + + length := uint32(header[0])<<24 | uint32(header[1])<<16 | uint32(header[2])<<8 | uint32(header[3]) + log.Debug().Uint32("length", length).Msg("Agent: Read message header") + + data := make([]byte, length) + if _, err := io.ReadFull(stream, data); err != nil { + log.Warn().Err(err).Msg("Agent: Failed to read message data") + atomic.AddInt64(&a.stats.Errors, 1) + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close stream after data read error") + } + return + } + + // 更新入站流量统计 + atomic.AddInt64(&a.stats.BytesIn, int64(len(header)+len(data))) + + var msg tunnel.Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Warn().Err(err).Str("data", string(data)).Msg("Agent: Failed to unmarshal message") + atomic.AddInt64(&a.stats.Errors, 1) + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close stream after unmarshal error") + } + return + } + + log.Debug().Str("type", string(msg.Type)).Msg("Received message from gateway") + + // 处理请求 + switch msg.Type { + case tunnel.MessageTypeHTTPRequest: + a.handleHTTPRequest(stream, &msg) + case tunnel.MessageTypeGRPCRequest: + a.handleGRPCRequest(stream, &msg) + case tunnel.MessageTypeDebugRequest: + a.handleDebugRequest(stream, &msg) + default: + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close stream for unknown message") + } + } + + // 计算响应时间并更新统计信息 + responseTime := time.Since(startTime) + atomic.AddInt64(&a.stats.Requests, 1) + a.recordResponseTime(responseTime) +} + +func (a *tunnelAgent) handleHTTPRequest(stream tunnel.Stream, msg *tunnel.Message) { + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close HTTP stream") + } + }() + + // Parse request meta from payload + var meta tunnel.RequestMeta + if len(msg.Payload) > 0 { + if err := json.Unmarshal(msg.Payload, &meta); err != nil { + log.Warn().Err(err).Msg("HTTP request: failed to parse request meta") + return + } + } + + // Find the HTTP endpoint from local services + var httpEndpoint *tunnel.Endpoint + a.mu.RLock() + for _, svc := range a.services { + for i := range svc.Endpoints { + if svc.Endpoints[i].Type == tunnel.EndpointTypeHTTP { + httpEndpoint = &svc.Endpoints[i] + break + } + } + if httpEndpoint != nil { + break + } + } + a.mu.RUnlock() + + if httpEndpoint == nil { + log.Warn().Msg("HTTP request: no HTTP endpoint found") + return + } + + // Normalize address: add localhost if address starts with ':' + address := httpEndpoint.Address + if len(address) > 0 && address[0] == ':' { + address = "127.0.0.1" + address + } + + log.Debug().Str("address", address).Str("path", meta.Path).Msg("Proxying HTTP request to local service") + + // Forward request to local HTTP service + conn, err := net.Dial("tcp", address) + if err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to connect to local HTTP service") + return + } + defer func() { + if err := conn.Close(); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to close HTTP connection") + } + }() + + // Bidirectional copy - wait for both directions to complete + var wg sync.WaitGroup + wg.Add(2) + + // stream -> conn (request from gateway to local service) + go func() { + defer wg.Done() + if _, err := io.Copy(conn, stream); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to copy request to local HTTP service") + } + // Close write side to signal end of request + if tcpConn, ok := conn.(*net.TCPConn); ok { + if err := tcpConn.CloseWrite(); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to close write side") + } + } + }() + + // conn -> stream (response from local service to gateway) + go func() { + defer wg.Done() + if _, err := io.Copy(stream, conn); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to copy response from local HTTP service") + } + }() + + wg.Wait() + log.Debug().Str("address", address).Msg("HTTP request completed") +} + +func (a *tunnelAgent) handleGRPCRequest(stream tunnel.Stream, msg *tunnel.Message) { + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close gRPC stream") + } + }() + + // Parse request meta from payload + var meta tunnel.RequestMeta + if len(msg.Payload) > 0 { + if err := json.Unmarshal(msg.Payload, &meta); err != nil { + log.Warn().Err(err).Msg("gRPC request: failed to parse request meta") + return + } + } + + // Find the gRPC endpoint from local services + var grpcEndpoint *tunnel.Endpoint + a.mu.RLock() + for _, svc := range a.services { + for i := range svc.Endpoints { + if svc.Endpoints[i].Type == tunnel.EndpointTypeGRPC { + grpcEndpoint = &svc.Endpoints[i] + break + } + } + if grpcEndpoint != nil { + break + } + } + a.mu.RUnlock() + + if grpcEndpoint == nil { + log.Warn().Msg("gRPC request: no gRPC endpoint found") + return + } + + // Normalize address: add localhost if address starts with ':' + address := grpcEndpoint.Address + if len(address) > 0 && address[0] == ':' { + address = "127.0.0.1" + address + } + + log.Debug().Str("address", address).Str("path", meta.Path).Msg("Proxying gRPC request to local service") + + // Forward request to local gRPC service + conn, err := net.Dial("tcp", address) + if err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to connect to local gRPC service") + return + } + defer func() { + if err := conn.Close(); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to close gRPC connection") + } + }() + + // Bidirectional copy - wait for both directions to complete + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if _, err := io.Copy(conn, stream); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to copy gRPC request to local service") + } + }() + + go func() { + defer wg.Done() + if _, err := io.Copy(stream, conn); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to copy gRPC response from local service") + } + }() + + wg.Wait() + log.Debug().Str("address", address).Msg("gRPC request completed") +} + +func (a *tunnelAgent) handleDebugRequest(stream tunnel.Stream, msg *tunnel.Message) { + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Agent: failed to close debug stream") + } + }() + + // Parse request meta from payload + var meta tunnel.RequestMeta + if len(msg.Payload) > 0 { + if err := json.Unmarshal(msg.Payload, &meta); err != nil { + log.Warn().Err(err).Msg("Debug request: failed to parse request meta") + return + } + } + + // Find the debug endpoint from local services + var debugEndpoint *tunnel.Endpoint + a.mu.RLock() + for _, svc := range a.services { + for i := range svc.Endpoints { + if svc.Endpoints[i].Type == tunnel.EndpointTypeDebug { + debugEndpoint = &svc.Endpoints[i] + break + } + } + if debugEndpoint != nil { + break + } + } + a.mu.RUnlock() + + if debugEndpoint == nil { + log.Warn().Msg("Debug request: no debug endpoint found") + return + } + + // Normalize address: add localhost if address starts with ':' + address := debugEndpoint.Address + if len(address) > 0 && address[0] == ':' { + address = "127.0.0.1" + address + } + + log.Debug().Str("address", address).Str("path", meta.Path).Msg("Proxying debug request to local service") + + // Forward request to local debug service + conn, err := net.Dial("tcp", address) + if err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to connect to local debug service") + return + } + defer func() { + if err := conn.Close(); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to close debug connection") + } + }() + + // Bidirectional copy - wait for both directions to complete + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if _, err := io.Copy(conn, stream); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to copy debug request to local service") + } + }() + + go func() { + defer wg.Done() + if _, err := io.Copy(stream, conn); err != nil { + log.Warn().Err(err).Str("address", address).Msg("Failed to copy debug response from local service") + } + }() + + wg.Wait() + log.Debug().Str("address", address).Msg("Debug request completed") +} + +func (a *tunnelAgent) reconnectLoop(ctx context.Context) { + defer a.wg.Done() + + baseInterval := time.Duration(a.cfg.ReconnectInterval) * time.Second + if baseInterval <= 0 { + baseInterval = 5 * time.Second + } + maxInterval := 60 * time.Second // 最大重连间隔 + currentInterval := baseInterval + attempt := 0 + + for { + select { + case <-a.stopCh: + return + case <-time.After(currentInterval): + if a.session == nil || a.session.IsClosed() { + a.status = tunnel.StatusReconnecting + attempt++ + log.Info().Int("attempt", attempt).Dur("interval", currentInterval).Msg("Attempting to reconnect to gateway") + if err := a.connect(ctx); err != nil { + log.Warn().Err(err).Int("attempt", attempt).Dur("interval", currentInterval).Msg("Failed to reconnect to gateway") + // 指数退避:每次失败后间隔翻倍 + currentInterval *= 2 + if currentInterval > maxInterval { + currentInterval = maxInterval + } + } else { + // 重连成功,重置退避计时器和尝试次数 + log.Info().Int("attempt", attempt).Msg("Successfully reconnected to gateway") + currentInterval = baseInterval + attempt = 0 + } + } else { + // 会话正常,重置退避计时器 + if attempt > 0 { + currentInterval = baseInterval + attempt = 0 + } + } + } + } +} + +// ServeHTTP implements http.Handler for the agent status +func (a *tunnelAgent) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mu.RLock() + services := make([]*tunnel.ServiceInfo, 0, len(a.services)) + for _, svc := range a.services { + services = append(services, svc) + } + a.mu.RUnlock() + + status := map[string]any{ + "status": a.status.String(), + "gateway": a.cfg.GatewayAddr, + "transport": a.cfg.Transport, + "services": services, + "num_streams": 0, + } + + if a.session != nil && !a.session.IsClosed() { + status["num_streams"] = a.session.NumStreams() + } + statsSnapshot := a.snapshotStats() + status["stats"] = statsSnapshot + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(status); err != nil { + log.Warn().Err(err).Msg("Agent: failed to encode status") + } +} + +func (a *tunnelAgent) updateLastActivity() { + a.statsMu.Lock() + a.stats.LastActivity = time.Now() + a.statsMu.Unlock() +} + +func (a *tunnelAgent) recordResponseTime(d time.Duration) { + a.statsMu.Lock() + a.stats.ResponseTimes = append(a.stats.ResponseTimes, d) + if len(a.stats.ResponseTimes) > 1000 { + a.stats.ResponseTimes = a.stats.ResponseTimes[1:] + } + a.stats.LastActivity = time.Now() + a.statsMu.Unlock() +} + +func (a *tunnelAgent) getOrCreateServiceStats(name string) *ServiceStats { + a.statsMu.Lock() + defer a.statsMu.Unlock() + if s, ok := a.stats.ServiceStats[name]; ok { + return s + } + s := &ServiceStats{} + a.stats.ServiceStats[name] = s + return s +} + +func (a *tunnelAgent) snapshotStats() *Stats { + a.statsMu.Lock() + defer a.statsMu.Unlock() + + snapshot := &Stats{ + Connections: atomic.LoadInt64(&a.stats.Connections), + Streams: atomic.LoadInt64(&a.stats.Streams), + BytesIn: atomic.LoadInt64(&a.stats.BytesIn), + BytesOut: atomic.LoadInt64(&a.stats.BytesOut), + Requests: atomic.LoadInt64(&a.stats.Requests), + Errors: atomic.LoadInt64(&a.stats.Errors), + LastActivity: a.stats.LastActivity, + } + + if len(a.stats.ResponseTimes) > 0 { + snapshot.ResponseTimes = append([]time.Duration(nil), a.stats.ResponseTimes...) + } + + if len(a.stats.ServiceStats) > 0 { + snapshot.ServiceStats = make(map[string]*ServiceStats, len(a.stats.ServiceStats)) + for name, s := range a.stats.ServiceStats { + snapshot.ServiceStats[name] = &ServiceStats{ + Requests: atomic.LoadInt64(&s.Requests), + Errors: atomic.LoadInt64(&s.Errors), + BytesIn: atomic.LoadInt64(&s.BytesIn), + BytesOut: atomic.LoadInt64(&s.BytesOut), + LastRequest: s.LastRequest, + ResponseTimes: func() []time.Duration { + if len(s.ResponseTimes) == 0 { + return nil + } + return append([]time.Duration(nil), s.ResponseTimes...) + }(), + } + } + } + + return snapshot +} diff --git a/core/tunnel/tunneldebug/agent.html b/core/tunnel/tunneldebug/agent.html new file mode 100644 index 000000000..2c000a3e9 --- /dev/null +++ b/core/tunnel/tunneldebug/agent.html @@ -0,0 +1,178 @@ + + + + + + Tunnel Agent - Debug Console + + + + + + + + + +
+ +
+
+

Tunnel Agent

+

Gateway 连接状态

+
+
+ 更新于 + +
+
+ + +
+
+ +
+
+
+
+ + +
+
+
Gateway 地址
+ +
+
+
服务名称
+
+
+
+
服务版本
+ +
+
+
端点数量
+
+
+
+ + +
+
+

本地端点

+
+
+ + +
+
+
+ + + + diff --git a/core/tunnel/tunneldebug/empty.html b/core/tunnel/tunneldebug/empty.html new file mode 100644 index 000000000..2ceaab335 --- /dev/null +++ b/core/tunnel/tunneldebug/empty.html @@ -0,0 +1,21 @@ + + + + + + Tunnel - 未配置 + + + +
+
+ 🌐 +
+

Tunnel 未配置

+

请先配置 Gateway 或 Agent 后再访问此页面

+ + 返回 Debug 首页 + +
+ + diff --git a/core/tunnel/tunneldebug/gateway.html b/core/tunnel/tunneldebug/gateway.html new file mode 100644 index 000000000..0eaa0133d --- /dev/null +++ b/core/tunnel/tunneldebug/gateway.html @@ -0,0 +1,433 @@ + + + + + + Tunnel Gateway - Debug Console + + + + + + + + + +
+ +
+
+

Tunnel Gateway

+

服务注册与代理管理

+
+
+ 更新于 + +
+
+ + +
+
+
注册服务
+
0
+
+
+
HTTP 端点
+
0
+
+
+
gRPC 端点
+
0
+
+
+
Debug 端点
+
0
+
+
+ +
+ +
+
+
+

Gateway 状态

+ + + + +
+
+
+
+ 🔗 + Tunnel +
+ :7007 +
+
+
+ 🌐 + HTTP Proxy +
+ :8888 +
+
+
+ 🔧 + Debug Proxy +
+ :6066 +
+
+
+
+ + +
+
+
+

已注册服务

+ +
+
+ + + + + + + + + + + + + + + +
服务版本状态端点注册时间
+
+
+ + +
+
+

快速访问

+
+
+
+
HTTP 服务访问
+ curl http://localhost:8888/{服务名}/api/... +
+
+
Debug 接口代理
+ curl http://localhost:6066/{服务名}/debug/pprof/ +
+
+
管理界面 API
+ curl http://localhost:6067/debug/tunnel/api/services +
+
+
+
+
+ + +
+
+
+

+ 📦 + +

+ +
+
+ +
+

+ ℹ️基本信息 +

+
+
+
服务名称
+
+
+
+
版本
+ +
+
+
服务 ID
+ +
+
+
状态
+ +
+
+
注册时间
+
+
+
+
最后心跳
+
+
+
+
+ + +
+

+ 🔌 + 端点列表 () +

+
+ + +
+
+ + +
+

+ 🏷️元数据 +

+
+ + + + +
+
+
+ + +
+

+ 💻访问示例 +

+
+
# HTTP 服务访问
+
curl http://localhost:8888//
+
# Debug 接口访问
+
curl http://localhost:6066//debug/pprof/
+
+
+
+
+
+
+ + + + diff --git a/core/tunnel/tunneldebug/html.go b/core/tunnel/tunneldebug/html.go new file mode 100644 index 000000000..97007f33a --- /dev/null +++ b/core/tunnel/tunneldebug/html.go @@ -0,0 +1,27 @@ +package tunneldebug + +import _ "embed" + +//go:embed gateway.html +var gatewayDashboardHTML string + +//go:embed agent.html +var agentDashboardHTML string + +//go:embed empty.html +var emptyDashboardHTML string + +// getGatewayDashboardHTML 返回 Gateway 仪表盘 HTML +func getGatewayDashboardHTML() string { + return gatewayDashboardHTML +} + +// getAgentDashboardHTML 返回 Agent 仪表盘 HTML +func getAgentDashboardHTML() string { + return agentDashboardHTML +} + +// getEmptyDashboardHTML 返回空状态页面 +func getEmptyDashboardHTML() string { + return emptyDashboardHTML +} diff --git a/core/tunnel/tunneldebug/tunneldebug.go b/core/tunnel/tunneldebug/tunneldebug.go new file mode 100644 index 000000000..b9086851c --- /dev/null +++ b/core/tunnel/tunneldebug/tunneldebug.go @@ -0,0 +1,221 @@ +// Package tunneldebug 提供 Tunnel Gateway 的 Web 管理界面 +package tunneldebug + +import ( + "encoding/json" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v3/middleware/adaptor" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/tunnel" +) + +var ( + globalGateway tunnel.Gateway + globalAgent tunnel.Agent + mu sync.RWMutex +) + +// SetGateway 设置全局 Gateway 实例 +func SetGateway(gw tunnel.Gateway) { + mu.Lock() + defer mu.Unlock() + globalGateway = gw +} + +// SetAgent 设置全局 Agent 实例 +func SetAgent(agent tunnel.Agent) { + mu.Lock() + defer mu.Unlock() + globalAgent = agent +} + +func init() { + // 注册路由到 debug app + debug.Get("/tunnel", adaptor.HTTPHandlerFunc(handleDashboard)) + debug.Get("/tunnel/", adaptor.HTTPHandlerFunc(handleDashboard)) + debug.Get("/tunnel/api/status", adaptor.HTTPHandlerFunc(handleAPIStatus)) + debug.Get("/tunnel/api/services", adaptor.HTTPHandlerFunc(handleAPIServices)) + debug.Get("/tunnel/api/services/:name", adaptor.HTTPHandlerFunc(handleAPIServiceDetail)) + debug.Get("/tunnel/api/stats", adaptor.HTTPHandlerFunc(handleAPIStats)) +} + +// handleDashboard 渲染主界面 +func handleDashboard(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + agent := globalAgent + mu.RUnlock() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + // 根据是 Gateway 还是 Agent 选择不同的 HTML + if gw != nil { + if _, err := w.Write([]byte(getGatewayDashboardHTML())); err != nil { + return + } + } else if agent != nil { + if _, err := w.Write([]byte(getAgentDashboardHTML())); err != nil { + return + } + } else { + if _, err := w.Write([]byte(getEmptyDashboardHTML())); err != nil { + return + } + } +} + +// handleAPIStatus 返回状态 JSON +func handleAPIStatus(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + agent := globalAgent + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + status := map[string]any{ + "timestamp": time.Now().Format(time.RFC3339), + } + + if gw != nil { + services := gw.Services() + status["gateway"] = map[string]any{ + "status": gw.Status().String(), + "service_count": len(services), + } + } + + if agent != nil { + // 获取 Agent 详细信息 + info := agent.Info() + status["agent"] = map[string]any{ + "status": info.Status, + "gateway_addr": info.GatewayAddr, + "service_name": info.ServiceName, + "service_version": info.ServiceVersion, + "endpoints": info.Endpoints, + } + } + + if err := json.NewEncoder(w).Encode(status); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// handleAPIServices 返回服务列表 +func handleAPIServices(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + if gw == nil { + if err := json.NewEncoder(w).Encode(map[string]any{ + "services": []any{}, + "total": 0, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + services := gw.Services() + + // 排序 + sort.Slice(services, func(i, j int) bool { + return services[i].Name < services[j].Name + }) + + if err := json.NewEncoder(w).Encode(map[string]any{ + "services": services, + "total": len(services), + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// handleAPIServiceDetail 返回服务详情 +func handleAPIServiceDetail(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + if gw == nil { + w.WriteHeader(http.StatusNotFound) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "gateway not configured"}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + // 从 URL 获取服务名 + name := strings.TrimPrefix(r.URL.Path, "/debug/tunnel/api/services/") + if name == "" { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "service name required"}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + svc, err := gw.GetService(name) + if err != nil { + w.WriteHeader(http.StatusNotFound) + if err := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + if err := json.NewEncoder(w).Encode(svc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// handleAPIStats 返回统计信息 +func handleAPIStats(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + stats := map[string]any{ + "timestamp": time.Now().Format(time.RFC3339), + } + + if gw != nil { + services := gw.Services() + + // 统计端点类型 + endpointStats := map[string]int{ + "http": 0, + "grpc": 0, + "debug": 0, + } + + for _, svc := range services { + for _, ep := range svc.Endpoints { + endpointStats[string(ep.Type)]++ + } + } + + stats["services"] = map[string]any{ + "total": len(services), + "endpoints": endpointStats, + } + } + + if err := json.NewEncoder(w).Encode(stats); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/core/tunnel/tunnelgateway/doc.go b/core/tunnel/tunnelgateway/doc.go new file mode 100644 index 000000000..3dd069f52 --- /dev/null +++ b/core/tunnel/tunnelgateway/doc.go @@ -0,0 +1,133 @@ +// Package tunnelgateway 提供隧道代理网关的便捷封装 +// +// 这个包是 tunnel.Gateway 的包装器,让用户可以更方便地创建和管理隧道代理网关。 +// +// # 基本用法 +// +// 创建一个接受 Agent 连接的 Gateway: +// +// gw := tunnelgateway.New(&tunnelgateway.Config{ +// ListenAddr: ":9000", +// Transport: "yamux", // 或 "quic", "kcp" +// HTTPPort: 8080, // 对外暴露的 HTTP 端口 +// GRPCPort: 9090, // 对外暴露的 gRPC 端口 +// DebugPort: 6060, // 对外暴露的 Debug 端口 +// }) +// +// ctx := context.Background() +// if err := gw.Start(ctx); err != nil { +// log.Fatal(err) +// } +// defer gw.Stop(ctx) + +// # 启动与验证 +// +// 1) 启动 Gateway(监听 Agent 连接端口 + 对外 HTTP 代理端口): +// +// gw := tunnelgateway.New(&tunnelgateway.Config{ +// ListenAddr: ":7000", // Agent 连接端口 +// Transport: "yamux", +// HTTPPort: 8080, // 对外 HTTP 代理端口 +// }) +// +// 2) 启动 Agent 并注册服务后,访问路由: +// +// GET http://gateway:8080/{service_name}/{path} +// +// 例如服务名为 demo-svc,后端路由 /hello: +// +// curl http://gateway:8080/demo-svc/hello +// +// 3) 查询服务列表: +// +// curl http://gateway:8080/ +// +// # 服务路由 +// +// Gateway 通过 URL 路径中的服务名称路由请求: +// +// HTTP 请求: http://gateway:8080/{service_name}/api/v1/users +// Debug 请求: http://gateway:6060/{service_name}/debug/pprof +// +// 如果不指定服务名称,Gateway 返回已注册服务列表: +// +// curl http://gateway:8080/ +// { +// "services": [ +// {"name": "user-service", "version": "v1.0.0", ...}, +// {"name": "order-service", "version": "v2.0.0", ...} +// ], +// "count": 2 +// } +// +// # 查询服务 +// +// 获取已注册的服务: +// +// // 获取所有服务 +// services := gw.Services() +// for _, svc := range services { +// fmt.Printf("Service: %s, Version: %s\n", svc.Name, svc.Version) +// } +// +// // 获取特定服务 +// svc, err := gw.GetService("my-service") +// if err != nil { +// log.Printf("Service not found: %v", err) +// } +// +// # 传输协议 +// +// 支持多种传输协议,需要与 Agent 使用相同的协议: +// +// - yamux: 基于 TCP 的多路复用(默认) +// - quic: 基于 UDP 的 QUIC 协议 +// - kcp: 基于 UDP 的 KCP 协议 +// +// # TLS 配置 +// +// 通过 TransportOptions 配置 TLS: +// +// gw := tunnelgateway.New(&tunnelgateway.Config{ +// ListenAddr: ":9000", +// Transport: "yamux", +// TransportOptions: &tunnelgateway.TransportOptions{ +// EnableTLS: true, +// CertFile: "/path/to/server.crt", +// KeyFile: "/path/to/server.key", +// CAFile: "/path/to/ca.crt", // 用于验证客户端证书 +// }, +// HTTPPort: 8080, +// }) +// +// # 状态监控 +// +// 检查 Gateway 状态: +// +// status := gw.Status() +// switch status { +// case tunnelgateway.StatusRunning: +// fmt.Println("Gateway 运行中") +// case tunnelgateway.StatusStopped: +// fmt.Println("Gateway 已停止") +// } +// +// # WebSocket 支持 +// +// Gateway 自动支持 WebSocket 请求的代理: +// +// // Agent 端运行 WebSocket 服务 +// // Gateway 会自动检测并处理 WebSocket 升级请求 +// ws://gateway:8080/{service_name}/ws +// +// # 健康检查 +// +// Gateway 自动进行服务健康检查,移除不健康的服务。 +// 健康检查间隔可通过配置调整: +// +// gw := tunnelgateway.New(&tunnelgateway.Config{ +// ListenAddr: ":9000", +// Transport: "yamux", +// HealthCheckInterval: 30, // 健康检查间隔(秒) +// }) +package tunnelgateway diff --git a/core/tunnel/tunnelgateway/gateway.go b/core/tunnel/tunnelgateway/gateway.go new file mode 100644 index 000000000..7979733a3 --- /dev/null +++ b/core/tunnel/tunnelgateway/gateway.go @@ -0,0 +1,232 @@ +// Package tunnelgateway 提供隧道代理网关实现 +// +// 这个包实现了 tunnel.Gateway 接口,负责接收服务注册并暴露服务。 +// +// 示例用法: +// +// gw := tunnelgateway.New(&tunnelgateway.Config{ +// ListenAddr: ":9000", +// Transport: "yamux", +// HTTPPort: 8080, +// GRPCPort: 9090, +// DebugPort: 6060, +// }) +// +// if err := gw.Start(ctx); err != nil { +// log.Fatal(err) +// } +// defer gw.Stop(ctx) +// +// 或使用 Builder 模式: +// +// gw, err := tunnelgateway.NewBuilder(). +// WithListenAddr(":9000"). +// WithHTTPPort(8080). +// WithDebugPort(6060). +// Build() +package tunnelgateway + +import ( + "context" + "fmt" + "net" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +// Config 是 Gateway 的配置 +type Config = tunnel.GatewayConfig + +// TransportOptions 传输层选项 +type TransportOptions = tunnel.TransportOptions + +// TLSConfig TLS 配置 +type TLSConfig = tunnel.TLSConfig + +// ServiceInfo 服务信息 +type ServiceInfo = tunnel.ServiceInfo + +// Endpoint 端点信息 +type Endpoint = tunnel.Endpoint + +// EndpointType 端点类型 +type EndpointType = tunnel.EndpointType + +// Status 网关状态 +type Status = tunnel.GatewayStatus + +// StatusInfo 网关状态信息 +type StatusInfo = tunnel.GatewayStatusInfo + +// 端点类型常量 +const ( + EndpointTypeHTTP = tunnel.EndpointTypeHTTP + EndpointTypeGRPC = tunnel.EndpointTypeGRPC + EndpointTypeDebug = tunnel.EndpointTypeDebug +) + +// 状态常量 +const ( + StatusStopped = tunnel.GatewayStatusStopped + StatusStarting = tunnel.GatewayStatusStarting + StatusRunning = tunnel.GatewayStatusRunning + StatusStopping = tunnel.GatewayStatusStopping +) + +// 传输协议常量 +const ( + TransportYamux = tunnel.TransportYamux + TransportQUIC = tunnel.TransportQUIC + TransportKCP = tunnel.TransportKCP +) + +// Gateway 是隧道代理网关的封装 +type Gateway struct { + inner tunnel.Gateway + cfg *Config +} + +// New 创建一个新的 Gateway 实例 +func New(cfg *Config) *Gateway { + return &Gateway{ + inner: NewGateway(cfg), + cfg: cfg, + } +} + +// Start 启动 Gateway,开始接受 Agent 连接 +func (g *Gateway) Start(ctx context.Context) error { + return g.inner.Start(ctx) +} + +// Stop 停止 Gateway +func (g *Gateway) Stop(ctx context.Context) error { + return g.inner.Stop(ctx) +} + +// Services 获取所有已注册的服务 +func (g *Gateway) Services() []*ServiceInfo { + return g.inner.Services() +} + +// GetService 获取指定名称的服务 +func (g *Gateway) GetService(name string) (*ServiceInfo, error) { + return g.inner.GetService(name) +} + +// Status 获取 Gateway 当前状态 +func (g *Gateway) Status() Status { + return g.inner.Status() +} + +// Forward 转发连接到指定服务的端点 +func (g *Gateway) Forward(ctx context.Context, serviceName string, endpointType EndpointType, conn net.Conn) error { + return g.inner.Forward(ctx, serviceName, endpointType, conn) +} + +// Config 获取 Gateway 配置 +func (g *Gateway) Config() *Config { + return g.cfg +} + +// Inner 获取底层的 tunnel.Gateway 实现 +func (g *Gateway) Inner() tunnel.Gateway { + return g.inner +} + +// Builder Gateway 构建器 +type Builder struct { + cfg *Config +} + +// NewBuilder 创建 Gateway 构建器 +func NewBuilder() *Builder { + cfg := tunnel.DefaultGatewayConfig() + return &Builder{cfg: &cfg} +} + +// WithListenAddr 设置监听地址 +func (b *Builder) WithListenAddr(addr string) *Builder { + b.cfg.ListenAddr = addr + return b +} + +// WithTransport 设置传输协议 +func (b *Builder) WithTransport(transport string) *Builder { + b.cfg.Transport = transport + return b +} + +// WithTransportOptions 设置传输层选项 +func (b *Builder) WithTransportOptions(opts *TransportOptions) *Builder { + b.cfg.TransportOptions = opts + return b +} + +// WithHTTPPort 设置 HTTP 端口 +func (b *Builder) WithHTTPPort(port int) *Builder { + b.cfg.HTTPPort = port + return b +} + +// WithGRPCPort 设置 gRPC 端口 +func (b *Builder) WithGRPCPort(port int) *Builder { + b.cfg.GRPCPort = port + return b +} + +// WithDebugPort 设置 Debug 端口 +func (b *Builder) WithDebugPort(port int) *Builder { + b.cfg.DebugPort = port + return b +} + +// WithHeartbeatInterval 设置心跳间隔(秒) +func (b *Builder) WithHeartbeatInterval(interval int) *Builder { + b.cfg.HeartbeatInterval = interval + return b +} + +// WithHeartbeatTimeout 设置心跳超时(秒) +func (b *Builder) WithHeartbeatTimeout(timeout int) *Builder { + b.cfg.HeartbeatTimeout = timeout + return b +} + +// WithHealthCheckInterval 设置健康检查间隔(秒) +func (b *Builder) WithHealthCheckInterval(interval int) *Builder { + b.cfg.HealthCheckInterval = interval + return b +} + +// WithTLS 设置 TLS 配置 +func (b *Builder) WithTLS(tls TLSConfig) *Builder { + b.cfg.TLS = tls + return b +} + +// WithConfig 使用完整配置 +func (b *Builder) WithConfig(cfg *Config) *Builder { + b.cfg = cfg + return b +} + +// Build 构建 Gateway +func (b *Builder) Build() (*Gateway, error) { + if b.cfg.ListenAddr == "" { + return nil, fmt.Errorf("tunnelgateway: listen address is required") + } + if b.cfg.Transport == "" { + b.cfg.Transport = TransportYamux + } + return New(b.cfg), nil +} + +// MustBuild 构建 Gateway,失败时 panic +func (b *Builder) MustBuild() *Gateway { + gw, err := b.Build() + if err != nil { + panic(err) + } + return gw +} diff --git a/core/tunnel/tunnelgateway/impl.go b/core/tunnel/tunnelgateway/impl.go new file mode 100644 index 000000000..2b410e4d9 --- /dev/null +++ b/core/tunnel/tunnelgateway/impl.go @@ -0,0 +1,1188 @@ +package tunnelgateway + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/pubgo/funk/v2/log" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +// linkRewritePatterns 用于重写 HTML 响应中的链接 +var linkRewritePatterns = []*regexp.Regexp{ + // href="/debug/..." or href='/debug/...' + regexp.MustCompile(`(href=["'])/debug/`), + // src="/debug/..." or src='/debug/...' + regexp.MustCompile(`(src=["'])/debug/`), + // action="/debug/..." + regexp.MustCompile(`(action=["'])/debug/`), + // fetch("/debug/..." or fetch('/debug/...' + regexp.MustCompile(`(fetch\(["'])/debug/`), + // url: "/debug/..." (for JavaScript) + regexp.MustCompile(`(url:\s*["'])/debug/`), + // "/debug/ in JSON responses + regexp.MustCompile(`(")/debug/`), +} + +var _ tunnel.Gateway = (*tunnelGateway)(nil) + +// NewGateway creates a new tunnel gateway +func NewGateway(cfg *tunnel.GatewayConfig) tunnel.Gateway { + return &tunnelGateway{ + cfg: cfg, + services: make(map[string]*registeredService), + status: tunnel.GatewayStatusStopped, + rateLimiter: NewRateLimiter(100), // 默认每秒100个请求 + } +} + +type registeredService struct { + info *tunnel.ServiceInfo + session tunnel.Session + agent string // agent identifier +} + +// RateLimiter 速率限制器 +type RateLimiter struct { + limits map[string]int // 服务名 -> 每秒最大请求数 + buckets map[string]*TokenBucket // 服务名 -> 令牌桶 + mu sync.RWMutex + defaultLimit int // 默认速率限制 +} + +// TokenBucket 令牌桶 +type TokenBucket struct { + capacity int // 令牌桶容量 + rate int // 每秒生成令牌数 + tokens float64 // 当前令牌数 + lastRefill time.Time // 上次填充时间 + mu sync.Mutex +} + +// NewRateLimiter 创建速率限制器 +func NewRateLimiter(defaultLimit int) *RateLimiter { + return &RateLimiter{ + limits: make(map[string]int), + buckets: make(map[string]*TokenBucket), + defaultLimit: defaultLimit, + } +} + +// SetLimit 设置服务的速率限制 +func (rl *RateLimiter) SetLimit(serviceName string, limit int) { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.limits[serviceName] = limit + // 重新创建令牌桶 + rl.buckets[serviceName] = NewTokenBucket(limit, limit) +} + +// GetLimit 获取服务的速率限制 +func (rl *RateLimiter) GetLimit(serviceName string) int { + rl.mu.RLock() + defer rl.mu.RUnlock() + if limit, ok := rl.limits[serviceName]; ok { + return limit + } + return rl.defaultLimit +} + +// Allow 检查是否允许请求 +func (rl *RateLimiter) Allow(serviceName string) bool { + rl.mu.RLock() + bucket, ok := rl.buckets[serviceName] + rl.mu.RUnlock() + if !ok { + rl.mu.Lock() + bucket, ok = rl.buckets[serviceName] + if !ok { + limit := rl.defaultLimit + if l, ok := rl.limits[serviceName]; ok { + limit = l + } + bucket = NewTokenBucket(limit, limit) + rl.buckets[serviceName] = bucket + } + rl.mu.Unlock() + } + return bucket.Allow() +} + +// NewTokenBucket 创建令牌桶 +func NewTokenBucket(capacity, rate int) *TokenBucket { + return &TokenBucket{ + capacity: capacity, + rate: rate, + tokens: float64(capacity), + lastRefill: time.Now(), + } +} + +// Allow 检查是否允许请求 +func (tb *TokenBucket) Allow() bool { + tb.mu.Lock() + defer tb.mu.Unlock() + + // 计算从上次填充到现在应该生成的令牌数 + now := time.Now() + timeElapsed := now.Sub(tb.lastRefill).Seconds() + tokensToAdd := timeElapsed * float64(tb.rate) + + // 填充令牌 + tb.tokens += tokensToAdd + if tb.tokens > float64(tb.capacity) { + tb.tokens = float64(tb.capacity) + } + tb.lastRefill = now + + // 检查是否有足够的令牌 + if tb.tokens >= 1.0 { + tb.tokens -= 1.0 + return true + } + return false +} + +type tunnelGateway struct { + cfg *tunnel.GatewayConfig + transport tunnel.Transport + listener tunnel.Listener + services map[string]*registeredService + status tunnel.GatewayStatus + authProvider tunnel.AuthProvider + rateLimiter *RateLimiter + + // 对外代理服务器 + httpServer *http.Server + debugServer *http.Server + + mu sync.RWMutex + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup + running atomic.Bool +} + +// SetAuthProvider 设置认证提供者 +func (g *tunnelGateway) SetAuthProvider(auth tunnel.AuthProvider) { + g.authProvider = auth +} + +func (g *tunnelGateway) Start(ctx context.Context) error { + if g.running.Load() { + return tunnel.ErrGatewayAlreadyRunning + } + + // Create transport + transport, err := tunnel.NewTransport(g.cfg.Transport, g.cfg.TransportOptions) + if err != nil { + return err + } + g.transport = transport + + // Start listening for agent connections (被动接受 Agent 连接) + listener, err := transport.Listen(ctx, g.cfg.ListenAddr) + if err != nil { + return err + } + g.listener = listener + + g.stopCh = make(chan struct{}) + g.running.Store(true) + g.status = tunnel.GatewayStatusRunning + + // Start accepting agent connections + g.wg.Add(1) + go g.acceptLoop() + + // Start health check loop + g.wg.Add(1) + go g.healthCheckLoop() + + // Start HTTP proxy server (对外暴露 HTTP 端口) + if g.cfg.HTTPPort > 0 { + g.wg.Add(1) + go g.startHTTPProxy() + } + + // Start Debug proxy server (对外暴露 Debug 端口) + if g.cfg.DebugPort > 0 { + g.wg.Add(1) + go g.startDebugProxy() + } + + log.Info(). + Str("tunnel_addr", g.cfg.ListenAddr). + Int("http_port", g.cfg.HTTPPort). + Int("grpc_port", g.cfg.GRPCPort). + Int("debug_port", g.cfg.DebugPort). + Msg("Gateway started, waiting for agents to connect...") + + return nil +} + +// startHTTPProxy 启动 HTTP 代理服务器,接收外部 HTTP 请求并转发到 Agent +func (g *tunnelGateway) startHTTPProxy() { + defer g.wg.Done() + + addr := fmt.Sprintf(":%d", g.cfg.HTTPPort) + g.httpServer = &http.Server{ + Addr: addr, + Handler: g.createProxyHandler(tunnel.EndpointTypeHTTP), + } + + log.Info().Str("addr", addr).Msg("HTTP proxy server started") + + if err := g.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("HTTP proxy server error") + } +} + +// startDebugProxy 启动 Debug 代理服务器,接收外部 Debug 请求并转发到 Agent +func (g *tunnelGateway) startDebugProxy() { + defer g.wg.Done() + + addr := fmt.Sprintf(":%d", g.cfg.DebugPort) + g.debugServer = &http.Server{ + Addr: addr, + Handler: g.createProxyHandler(tunnel.EndpointTypeDebug), + } + + log.Info().Str("addr", addr).Msg("Debug proxy server started") + + if err := g.debugServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("Debug proxy server error") + } +} + +// createProxyHandler 创建 HTTP 代理处理器 +// URL 格式: /{service_name}/path... -> 转发到对应 Agent 的本地服务 +func (g *tunnelGateway) createProxyHandler(endpointType tunnel.EndpointType) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 解析 URL: /{service_name}/path... + path := r.URL.Path + if !strings.HasPrefix(path, "/") { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + parts := strings.SplitN(path[1:], "/", 2) + if len(parts) == 0 || parts[0] == "" { + // 没有指定服务名,返回服务列表 + g.handleServiceList(w, r) + return + } + + serviceName := parts[0] + subPath := "/" + if len(parts) > 1 { + subPath = "/" + parts[1] + } + + // 查找服务 + g.mu.RLock() + svc, ok := g.services[serviceName] + g.mu.RUnlock() + + if !ok { + http.Error(w, fmt.Sprintf("service not found: %s", serviceName), http.StatusNotFound) + return + } + + if svc.session == nil || svc.session.IsClosed() { + http.Error(w, fmt.Sprintf("service unavailable: %s", serviceName), http.StatusServiceUnavailable) + return + } + + // 转发请求到 Agent + g.proxyToAgent(w, r, svc, endpointType, subPath) + }) +} + +// handleServiceList 返回已注册的服务列表 +func (g *tunnelGateway) handleServiceList(w http.ResponseWriter, r *http.Request) { + g.mu.RLock() + services := make([]*tunnel.ServiceInfo, 0, len(g.services)) + for _, svc := range g.services { + services = append(services, svc.info) + } + g.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "services": services, + "count": len(services), + }); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to encode service list") + } +} + +// proxyToAgent 将 HTTP 请求代理到 Agent +func (g *tunnelGateway) proxyToAgent(w http.ResponseWriter, r *http.Request, svc *registeredService, endpointType tunnel.EndpointType, subPath string) { + ctx := r.Context() + serviceName := svc.info.Name + + // 检查是否为 WebSocket 升级请求 + isWebSocket := strings.EqualFold(r.Header.Get("Upgrade"), "websocket") + + // 检查速率限制 + if !g.rateLimiter.Allow(serviceName) { + log.Warn().Str("service", serviceName).Msg("Rate limit exceeded") + http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) + return + } + + log.Debug(). + Str("service", serviceName). + Str("endpointType", string(endpointType)). + Str("subPath", subPath). + Str("method", r.Method). + Bool("isWebSocket", isWebSocket). + Bool("sessionClosed", svc.session.IsClosed()). + Int("numStreams", svc.session.NumStreams()). + Msg("Gateway: Proxying request to agent") + + // 确定流优先级 + priority := 5 // 默认中优先级 + switch endpointType { + case tunnel.EndpointTypeDebug: + priority = 3 // 调试请求使用较高优先级 + case tunnel.EndpointTypeGRPC: + priority = 4 // gRPC 请求使用中等优先级 + } + + // 打开到 Agent 的 stream + stream, err := svc.session.OpenWithPriority(ctx, priority) + if err != nil { + // 降级到普通优先级 + stream, err = svc.session.Open(ctx) + if err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: Failed to open stream to agent") + http.Error(w, fmt.Sprintf("failed to open stream: %v", err), http.StatusInternalServerError) + return + } + } + + log.Debug().Str("service", serviceName).Int("priority", priority).Msg("Gateway: Stream opened to agent") + + // Build request meta + meta := tunnel.RequestMeta{ + ServiceID: svc.info.ID, + EndpointType: endpointType, + Path: subPath, + Method: r.Method, + } + payload, err := json.Marshal(meta) + if err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to marshal request meta") + http.Error(w, fmt.Sprintf("failed to build request meta: %v", err), http.StatusInternalServerError) + return + } + + // 发送请求消息给 Agent + msg := &tunnel.Message{ + Type: g.endpointTypeToMessageType(endpointType), + Payload: payload, + } + + if err := g.sendMessage(stream, msg); err != nil { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: Failed to send message to agent") + http.Error(w, fmt.Sprintf("failed to send message: %v", err), http.StatusInternalServerError) + return + } + + log.Debug().Str("service", serviceName).Str("msgType", string(msg.Type)).Msg("Gateway: Message sent to agent, starting proxy") + + // 修改请求路径为子路径 + r.URL.Path = subPath + r.RequestURI = subPath + if r.URL.RawQuery != "" { + r.RequestURI = subPath + "?" + r.URL.RawQuery + } + + // WebSocket 请求使用双向 TCP 代理 + if isWebSocket { + g.proxyWebSocket(w, r, stream, serviceName) + return + } + + // 普通 HTTP 请求使用 httputil 代理 + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + }() + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + // 保持原始请求 + }, + Transport: &streamRoundTripper{stream: stream, request: r}, + ModifyResponse: func(resp *http.Response) error { + // 只对 HTML 和 JSON 响应重写链接 + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/html") && !strings.Contains(contentType, "application/json") { + return nil + } + + // 读取响应体 + body, err := io.ReadAll(resp.Body) + if closeErr := resp.Body.Close(); closeErr != nil { + log.Warn().Err(closeErr).Str("service", serviceName).Msg("Gateway: failed to close response body") + } + if err != nil { + return err + } + + // 重写链接: /debug/ -> /{serviceName}/debug/ + replacement := "${1}/" + serviceName + "/debug/" + for _, pattern := range linkRewritePatterns { + body = pattern.ReplaceAll(body, []byte(replacement)) + } + + // 更新响应体和 Content-Length + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) + // 移除 Content-Encoding,因为我们已经解压了 + resp.Header.Del("Content-Encoding") + + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Warn().Err(err).Str("service", serviceName).Msg("Proxy error") + w.WriteHeader(http.StatusBadGateway) + }, + } + + proxy.ServeHTTP(w, r) +} + +// proxyWebSocket 处理 WebSocket 代理 +func (g *tunnelGateway) proxyWebSocket(w http.ResponseWriter, r *http.Request, stream tunnel.Stream, serviceName string) { + log.Debug().Str("service", serviceName).Str("path", r.URL.Path).Msg("Gateway: Starting WebSocket proxy") + + // 获取底层 TCP 连接 + hijacker, ok := w.(http.Hijacker) + if !ok { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + http.Error(w, "WebSocket not supported", http.StatusInternalServerError) + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + log.Warn().Err(err).Str("service", serviceName).Msg("Failed to hijack connection") + http.Error(w, "Failed to hijack connection", http.StatusInternalServerError) + return + } + + // 将原始 HTTP 请求写入 stream,让 Agent 处理 WebSocket 升级 + if err := r.Write(stream); err != nil { + if err := clientConn.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close client connection") + } + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + log.Warn().Err(err).Str("service", serviceName).Msg("Failed to write WebSocket request to stream") + return + } + + log.Debug().Str("service", serviceName).Msg("Gateway: WebSocket request forwarded, starting bidirectional copy") + + // 双向复制数据 + var wg sync.WaitGroup + wg.Add(2) + + // Client -> Agent + go func() { + defer wg.Done() + if _, err := io.Copy(stream, clientConn); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: websocket copy client->agent failed") + } + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + }() + + // Agent -> Client + go func() { + defer wg.Done() + if _, err := io.Copy(clientConn, stream); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: websocket copy agent->client failed") + } + if err := clientConn.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close client connection") + } + }() + + wg.Wait() + log.Debug().Str("service", serviceName).Msg("Gateway: WebSocket proxy finished") +} + +// streamRoundTripper 实现 http.RoundTripper,通过 stream 转发请求 +type streamRoundTripper struct { + stream tunnel.Stream + request *http.Request +} + +func (t *streamRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // 将请求写入 stream + if err := req.Write(t.stream); err != nil { + return nil, err + } + + // 从 stream 读取响应 + return http.ReadResponse(bufio.NewReader(t.stream), req) +} + +func (g *tunnelGateway) Stop(ctx context.Context) error { + if !g.running.Load() { + return nil + } + + g.stopOnce.Do(func() { + close(g.stopCh) + }) + + // 关闭 HTTP 代理服务器 + shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + if g.httpServer != nil { + if err := g.httpServer.Shutdown(shutdownCtx); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to shutdown HTTP server") + } + } + if g.debugServer != nil { + if err := g.debugServer.Shutdown(shutdownCtx); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to shutdown debug server") + } + } + + // Close listener + if g.listener != nil { + if err := g.listener.Close(); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to close listener") + } + } + + // Close all sessions + g.mu.Lock() + for _, svc := range g.services { + if svc.session != nil { + if err := svc.session.Close(); err != nil { + log.Warn().Err(err).Str("service", svc.info.Name).Msg("Gateway: failed to close session") + } + } + } + g.services = make(map[string]*registeredService) + g.mu.Unlock() + + // Wait for goroutines with timeout + done := make(chan struct{}) + go func() { + g.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + case <-time.After(3 * time.Second): + log.Warn().Msg("Gateway stop timeout, force closing") + } + + g.running.Store(false) + g.status = tunnel.GatewayStatusStopped + return nil +} + +func (g *tunnelGateway) Services() []*tunnel.ServiceInfo { + g.mu.RLock() + defer g.mu.RUnlock() + + services := make([]*tunnel.ServiceInfo, 0, len(g.services)) + for _, svc := range g.services { + services = append(services, svc.info) + } + return services +} + +func (g *tunnelGateway) GetService(name string) (*tunnel.ServiceInfo, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + svc, ok := g.services[name] + if !ok { + return nil, tunnel.ErrServiceNotFound + } + return svc.info, nil +} + +func (g *tunnelGateway) Status() tunnel.GatewayStatus { + return g.status +} + +func (g *tunnelGateway) Forward(ctx context.Context, serviceName string, endpointType tunnel.EndpointType, conn net.Conn) error { + g.mu.RLock() + svc, ok := g.services[serviceName] + g.mu.RUnlock() + + if !ok { + return tunnel.ErrServiceNotFound + } + + if svc.session == nil || svc.session.IsClosed() { + return tunnel.ErrSessionClosed + } + + // Open a stream to the agent + stream, err := svc.session.Open(ctx) + if err != nil { + return err + } + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + }() + + // Build request meta + meta := tunnel.RequestMeta{ + ServiceID: svc.info.ID, + EndpointType: endpointType, + } + payload, err := json.Marshal(meta) + if err != nil { + return err + } + + // Send forward request + msg := &tunnel.Message{ + Type: g.endpointTypeToMessageType(endpointType), + Payload: payload, + } + + if err := g.sendMessage(stream, msg); err != nil { + return err + } + + // Bidirectional copy + errCh := make(chan error, 2) + go func() { + _, err := io.Copy(stream, conn) + errCh <- err + }() + go func() { + _, err := io.Copy(conn, stream) + errCh <- err + }() + + // Wait for one direction to complete + <-errCh + return nil +} + +func (g *tunnelGateway) endpointTypeToMessageType(et tunnel.EndpointType) tunnel.MessageType { + switch et { + case tunnel.EndpointTypeHTTP: + return tunnel.MessageTypeHTTPRequest + case tunnel.EndpointTypeGRPC: + return tunnel.MessageTypeGRPCRequest + case tunnel.EndpointTypeDebug: + return tunnel.MessageTypeDebugRequest + default: + return tunnel.MessageTypeHTTPRequest + } +} + +func (g *tunnelGateway) sendMessage(stream tunnel.Stream, msg *tunnel.Message) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + + // Write length prefix (4 bytes) + data + length := uint32(len(data)) + header := []byte{ + byte(length >> 24), + byte(length >> 16), + byte(length >> 8), + byte(length), + } + + if _, err := stream.Write(header); err != nil { + return err + } + if _, err := stream.Write(data); err != nil { + return err + } + return nil +} + +func (g *tunnelGateway) acceptLoop() { + defer g.wg.Done() + + for { + select { + case <-g.stopCh: + return + default: + } + + session, err := g.listener.Accept() + if err != nil { + select { + case <-g.stopCh: + return + default: + log.Warn().Err(err).Msg("Failed to accept connection") + continue + } + } + + go g.handleSession(session) + } +} + +func (g *tunnelGateway) handleSession(session tunnel.Session) { + agentID := session.RemoteAddr().String() + log.Info().Str("agent", agentID).Msg("Agent connected") + + for { + select { + case <-g.stopCh: + return + default: + } + + if session.IsClosed() { + g.removeAgentServices(agentID) + return + } + + stream, err := session.Accept() + if err != nil { + if session.IsClosed() { + g.removeAgentServices(agentID) + return + } + log.Warn().Err(err).Msg("Failed to accept stream") + continue + } + + go g.handleStream(agentID, session, stream) + } +} + +func (g *tunnelGateway) handleStream(agentID string, session tunnel.Session, stream tunnel.Stream) { + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("agent", agentID).Msg("Gateway: failed to close stream") + } + }() + + // Read message header + header := make([]byte, 4) + if _, err := io.ReadFull(stream, header); err != nil { + log.Warn().Err(err).Msg("Failed to read message header") + return + } + + length := uint32(header[0])<<24 | uint32(header[1])<<16 | uint32(header[2])<<8 | uint32(header[3]) + data := make([]byte, length) + if _, err := io.ReadFull(stream, data); err != nil { + log.Warn().Err(err).Msg("Failed to read message data") + return + } + + var msg tunnel.Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Warn().Err(err).Msg("Failed to unmarshal message") + return + } + + switch msg.Type { + case tunnel.MessageTypeRegister: + g.handleRegister(agentID, session, &msg) + case tunnel.MessageTypeDeregister: + g.handleDeregister(&msg) + case tunnel.MessageTypeHeartbeat: + g.handleHeartbeat(agentID) + default: + log.Warn().Uint8("type", uint8(msg.Type)).Msg("Unknown message type") + } +} + +func (g *tunnelGateway) handleRegister(agentID string, session tunnel.Session, msg *tunnel.Message) { + if len(msg.Payload) == 0 { + log.Warn().Str("agent", agentID).Msg("Register: empty payload") + return + } + + var service tunnel.ServiceInfo + if err := json.Unmarshal(msg.Payload, &service); err != nil { + log.Warn().Err(err).Str("agent", agentID).Msg("Register: failed to parse service info") + return + } + + // 使用认证提供者验证服务 + if g.authProvider != nil { + if err := g.authProvider.Authenticate(&service); err != nil { + log.Warn().Err(err).Str("agent", agentID).Str("service", service.Name).Msg("Register: authentication failed") + return + } + } + + g.mu.Lock() + g.services[service.Name] = ®isteredService{ + info: &service, + session: session, + agent: agentID, + } + g.mu.Unlock() + + log.Info().Str("service", service.Name).Str("agent", agentID).Msg("Service registered") +} + +func (g *tunnelGateway) handleDeregister(msg *tunnel.Message) { + if len(msg.Payload) == 0 { + return + } + + serviceName := string(msg.Payload) + + g.mu.Lock() + delete(g.services, serviceName) + g.mu.Unlock() + + log.Info().Str("service", serviceName).Msg("Service deregistered") +} + +func (g *tunnelGateway) handleHeartbeat(agentID string) { + // Update last heartbeat time (could be used for health checking) + log.Debug().Str("agent", agentID).Msg("Heartbeat received") +} + +func (g *tunnelGateway) removeAgentServices(agentID string) { + g.mu.Lock() + defer g.mu.Unlock() + + for name, svc := range g.services { + if svc.agent == agentID { + delete(g.services, name) + log.Info().Str("service", name).Str("agent", agentID).Msg("Service removed (agent disconnected)") + } + } +} + +func (g *tunnelGateway) healthCheckLoop() { + defer g.wg.Done() + + interval := time.Duration(g.cfg.HealthCheckInterval) * time.Second + if interval <= 0 { + interval = 30 * time.Second + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-g.stopCh: + return + case <-ticker.C: + g.checkServices() + } + } +} + +func (g *tunnelGateway) checkServices() { + g.mu.Lock() + defer g.mu.Unlock() + + for name, svc := range g.services { + if svc.session == nil || svc.session.IsClosed() { + delete(g.services, name) + log.Info().Str("service", name).Msg("Service removed (session closed)") + continue + } + + // 检查服务健康状态 + status := g.checkServiceHealth(svc) + if status != tunnel.ServiceStatusOnline { + log.Warn().Str("service", name).Str("status", string(status)).Msg("Service health check failed") + } + } +} + +func (g *tunnelGateway) checkServiceHealth(svc *registeredService) tunnel.ServiceStatus { + // 检查会话状态 + if svc.session == nil || svc.session.IsClosed() { + return tunnel.ServiceStatusOffline + } + + // 检查每个端点的健康状态 + allHealthy := true + for i, endpoint := range svc.info.Endpoints { + if !g.checkEndpointHealth(svc, &endpoint) { + allHealthy = false + log.Warn().Str("service", svc.info.Name).Str("endpoint", string(endpoint.Type)).Str("address", endpoint.Address).Msg("Endpoint health check failed") + // 更新端点健康状态 + svc.info.Endpoints[i].Metadata["health_status"] = "unhealthy" + } else { + svc.info.Endpoints[i].Metadata["health_status"] = "healthy" + } + } + + if allHealthy { + svc.info.Status = tunnel.ServiceStatusOnline + return tunnel.ServiceStatusOnline + } else { + svc.info.Status = tunnel.ServiceStatusUnhealthy + return tunnel.ServiceStatusUnhealthy + } +} + +func (g *tunnelGateway) checkEndpointHealth(svc *registeredService, endpoint *tunnel.Endpoint) bool { + // 创建健康检查上下文 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // 根据端点类型进行不同的健康检查 + switch endpoint.Type { + case tunnel.EndpointTypeHTTP: + return g.checkHTTPEndpointHealth(ctx, svc, endpoint) + case tunnel.EndpointTypeGRPC: + return g.checkGRPCEndpointHealth(ctx, svc, endpoint) + case tunnel.EndpointTypeDebug: + return g.checkDebugEndpointHealth(ctx, svc, endpoint) + default: + // 其他类型端点默认认为健康 + return true + } +} + +func (g *tunnelGateway) checkHTTPEndpointHealth(ctx context.Context, svc *registeredService, endpoint *tunnel.Endpoint) bool { + // 打开到 Agent 的 stream + stream, err := svc.session.Open(ctx) + if err != nil { + return false + } + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to close health check stream") + } + }() + + // 构建健康检查请求 + meta := tunnel.RequestMeta{ + ServiceID: svc.info.ID, + EndpointType: tunnel.EndpointTypeHTTP, + Path: endpoint.Path + "/health", // 假设健康检查路径为 /health + Method: "GET", + } + payload, err := json.Marshal(meta) + if err != nil { + log.Warn().Err(err).Str("service", svc.info.Name).Msg("Gateway: failed to marshal health check meta") + return false + } + + // 发送健康检查请求 + msg := &tunnel.Message{ + Type: tunnel.MessageTypeHTTPRequest, + Payload: payload, + } + if err := g.sendMessage(stream, msg); err != nil { + return false + } + + // 发送 HTTP 请求 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint.Address+"/health", nil) + if err != nil { + return false + } + if err := req.Write(stream); err != nil { + return false + } + + // 读取响应 + resp, err := http.ReadResponse(bufio.NewReader(stream), req) + if err != nil { + return false + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to close health check response body") + } + }() + + // 检查响应状态码 + return resp.StatusCode == http.StatusOK +} + +func (g *tunnelGateway) checkGRPCEndpointHealth(ctx context.Context, svc *registeredService, endpoint *tunnel.Endpoint) bool { + // gRPC 健康检查实现 + // 这里简化处理,实际应该实现 gRPC 健康检查协议 + return true +} + +func (g *tunnelGateway) checkDebugEndpointHealth(ctx context.Context, svc *registeredService, endpoint *tunnel.Endpoint) bool { + // Debug 端点健康检查 + // 简化处理,检查是否能打开流 + stream, err := svc.session.Open(ctx) + if err != nil { + return false + } + if err := stream.Close(); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to close debug health check stream") + return false + } + return true +} + +// FiberHandler returns a Fiber handler for the gateway HTTP proxy +func (g *tunnelGateway) FiberHandler() fiber.Handler { + return func(c fiber.Ctx) error { + serviceName := c.Params("service") + if serviceName == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "service name required", + }) + } + + // Extract sub path after service name + fullPath := c.Path() + subPath := "" + if idx := strings.Index(fullPath, serviceName); idx >= 0 { + subPath = fullPath[idx+len(serviceName):] + } + + g.mu.RLock() + svc, ok := g.services[serviceName] + g.mu.RUnlock() + + if !ok { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "service not found", + }) + } + + if svc.session == nil || svc.session.IsClosed() { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "service unavailable", + }) + } + + // Open a stream to the agent + ctx := c.Context() + stream, err := svc.session.Open(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("failed to open stream: %v", err), + }) + } + defer func() { + if err := stream.Close(); err != nil { + log.Warn().Err(err).Str("service", serviceName).Msg("Gateway: failed to close stream") + } + }() + + // Build request meta + meta := tunnel.RequestMeta{ + ServiceID: svc.info.ID, + EndpointType: tunnel.EndpointTypeHTTP, + Path: subPath, + Method: c.Method(), + } + payload, err := json.Marshal(meta) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("failed to build request meta: %v", err), + }) + } + + // Send HTTP request message + msg := &tunnel.Message{ + Type: tunnel.MessageTypeHTTPRequest, + Payload: payload, + } + + if err := g.sendMessage(stream, msg); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("failed to send message: %v", err), + }) + } + + // Write the original request + req := c.Request() + if _, err := stream.Write(req.Header.Header()); err != nil { + return err + } + if _, err := stream.Write(req.Body()); err != nil { + return err + } + + // Read response + buf := make([]byte, 32*1024) + for { + n, err := stream.Read(buf) + if n > 0 { + if _, writeErr := c.Response().BodyWriter().Write(buf[:n]); writeErr != nil { + return writeErr + } + } + if err != nil { + if err == io.EOF { + break + } + return err + } + } + + return nil + } +} + +// ServeHTTP implements http.Handler for the gateway status +func (g *tunnelGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { + services := g.Services() + + status := map[string]any{ + "status": g.status.String(), + "listen_addr": g.cfg.ListenAddr, + "transport": g.cfg.Transport, + "num_services": len(services), + "services": services, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(status); err != nil { + log.Warn().Err(err).Msg("Gateway: failed to encode status") + } +} diff --git a/core/tunnel/types.go b/core/tunnel/types.go new file mode 100644 index 000000000..08906f210 --- /dev/null +++ b/core/tunnel/types.go @@ -0,0 +1,369 @@ +// Package tunnel 提供服务代理网关的核心功能 +// +// 这个包实现了一个服务注册监控网关系统,允许服务通过反向代理的方式注册到代理网关, +// 然后通过代理网关暴露服务的 API、gRPC、debug 调试等接口。 +// +// 主要功能: +// - 服务注册:服务启动后自动注册到代理网关 +// - 多协议传输:支持 yamux、QUIC、KCP 等 +// - 服务暴露:通过代理网关暴露 HTTP API、gRPC 和 debug 调试接口 +// - 健康检查:自动监控服务健康状态 +// - 运维监控:提供统一的调试、监控入口 +// +// 架构设计: +// +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Proxy Gateway Server │ +// │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +// │ │ HTTP API │ │ gRPC │ │ Debug │ │ +// │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +// │ │ │ │ │ +// │ ┌──────┴───────────────┴───────────────┴──────┐ │ +// │ │ Service Router │ │ +// │ └──────────────────────┬──────────────────────┘ │ +// │ │ │ +// │ ┌──────────────────────┴──────────────────────┐ │ +// │ │ Transport Manager │ │ +// │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +// │ │ │ yamux │ │ QUIC │ │ KCP │ ... │ │ +// │ │ └─────────┘ └─────────┘ └─────────┘ │ │ +// │ └──────────────────────────────────────────────┘ │ +// └─────────────────────────────────────────────────────────────────┘ +// ↑ +// ┌─────┴─────┐ +// │ Tunnel │ +// └─────┬─────┘ +// ↓ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Service Agent │ +// │ ┌──────────────────────┴──────────────────────┐ │ +// │ │ Transport Client │ │ +// │ └──────────────────────┬──────────────────────┘ │ +// │ ┌───────────────┼───────────────┐ │ +// │ ┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐ │ +// │ │ HTTP API │ │ gRPC │ │ Debug │ │ +// │ └─────────────┘ └─────────────┘ └─────────────┘ │ +// └─────────────────────────────────────────────────────────────────┘ +package tunnel + +import ( + "context" + "io" + "net" + "time" +) + +// ServiceInfo 服务信息 +type ServiceInfo struct { + // ID 服务唯一标识 + ID string `json:"id,omitempty"` + // Name 服务名称 + Name string `json:"name,omitempty"` + // Version 服务版本 + Version string `json:"version,omitempty"` + // Metadata 元数据 + Metadata map[string]string `json:"metadata,omitempty"` + // Endpoints 服务端点列表 + Endpoints []Endpoint `json:"endpoints,omitempty"` + // RegisterTime 注册时间 + RegisterTime time.Time `json:"register_time,omitempty"` + // LastHeartbeat 最后心跳时间 + LastHeartbeat time.Time `json:"last_heartbeat,omitempty"` + // Status 服务状态 + Status ServiceStatus `json:"status,omitempty"` +} + +// Endpoint 服务端点 +type Endpoint struct { + // Type 端点类型: http, grpc, debug + Type EndpointType `json:"type,omitempty"` + // Path 端点路径 + Path string `json:"path,omitempty"` + // Port 端点端口(本地) + Port int `json:"port,omitempty"` + // Address 端点地址 + Address string `json:"address,omitempty"` + // Metadata 端点元数据 + Metadata map[string]string `json:"metadata,omitempty"` +} + +// EndpointType 端点类型 +type EndpointType string + +const ( + EndpointTypeHTTP EndpointType = "http" + EndpointTypeGRPC EndpointType = "grpc" + EndpointTypeDebug EndpointType = "debug" + EndpointTypeTCP EndpointType = "tcp" + EndpointTypeUDP EndpointType = "udp" +) + +// ServiceStatus 服务状态 +type ServiceStatus string + +const ( + ServiceStatusOnline ServiceStatus = "online" + ServiceStatusOffline ServiceStatus = "offline" + ServiceStatusUnhealthy ServiceStatus = "unhealthy" +) + +// Transport 传输层接口,支持多种传输协议 +// 实现者需要提供底层连接的多路复用能力 +type Transport interface { + // Name 传输协议名称 + Name() string + // Dial 创建到服务端的连接 + Dial(ctx context.Context, addr string) (Session, error) + // Listen 监听来自客户端的连接 + Listen(ctx context.Context, addr string) (Listener, error) +} + +// Session 代表一个与对端的会话,可以创建多个流 +type Session interface { + io.Closer + + // Open 打开一个新的流 + Open(ctx context.Context) (Stream, error) + // OpenWithPriority 打开指定优先级的流(1-10,1最高) + OpenWithPriority(ctx context.Context, priority int) (Stream, error) + // Accept 接受一个新的流 + Accept() (Stream, error) + // IsClosed 会话是否已关闭 + IsClosed() bool + // NumStreams 当前活跃流数量 + NumStreams() int + // LocalAddr 本地地址 + LocalAddr() net.Addr + // RemoteAddr 远程地址 + RemoteAddr() net.Addr +} + +// Stream 代表一个多路复用的流连接 +type Stream interface { + io.ReadWriteCloser + + // LocalAddr 本地地址 + LocalAddr() net.Addr + // RemoteAddr 远程地址 + RemoteAddr() net.Addr + // SetDeadline 设置读写超时 + SetDeadline(t time.Time) error + // SetReadDeadline 设置读超时 + SetReadDeadline(t time.Time) error + // SetWriteDeadline 设置写超时 + SetWriteDeadline(t time.Time) error + // Priority 获取流优先级 + Priority() int +} + +// Listener 监听器接口 +type Listener interface { + io.Closer + // Accept 接受新的会话连接 + Accept() (Session, error) + // Addr 监听地址 + Addr() net.Addr +} + +// Agent 服务代理客户端接口 +// 运行在服务节点上,负责将本地服务注册到代理网关 +type Agent interface { + // Start 启动代理客户端 + Start(ctx context.Context) error + // Stop 停止代理客户端 + Stop(ctx context.Context) error + // Register 注册服务 + Register(ctx context.Context, service *ServiceInfo) error + // Deregister 注销服务 + Deregister(ctx context.Context, serviceID string) error + // Status 获取代理客户端状态 + Status() AgentStatus + // Info 获取 Agent 信息(用于调试显示) + Info() *AgentInfo +} + +// AgentInfo Agent 信息 +type AgentInfo struct { + GatewayAddr string `json:"gateway_addr"` + ServiceName string `json:"service_name"` + ServiceVersion string `json:"service_version"` + Endpoints []Endpoint `json:"endpoints"` + Status string `json:"status"` +} + +// AgentStatus 代理客户端状态 +type AgentStatus int + +const ( + // StatusDisconnected 未连接 + StatusDisconnected AgentStatus = iota + // StatusConnecting 连接中 + StatusConnecting + // StatusConnected 已连接 + StatusConnected + // StatusReconnecting 重连中 + StatusReconnecting +) + +// String 返回状态的字符串表示 +func (s AgentStatus) String() string { + switch s { + case StatusDisconnected: + return "disconnected" + case StatusConnecting: + return "connecting" + case StatusConnected: + return "connected" + case StatusReconnecting: + return "reconnecting" + default: + return "unknown" + } +} + +// AgentStatusInfo 代理客户端状态信息 +type AgentStatusInfo struct { + // Connected 是否已连接到网关 + Connected bool `json:"connected"` + // GatewayAddr 网关地址 + GatewayAddr string `json:"gateway_addr"` + // Transport 使用的传输协议 + Transport string `json:"transport"` + // Services 已注册的服务列表 + Services []ServiceInfo `json:"services"` + // LastError 最后一次错误 + LastError string `json:"last_error,omitempty"` + // ConnectedAt 连接时间 + ConnectedAt time.Time `json:"connected_at,omitempty"` +} + +// AuthProvider 认证提供者接口 +type AuthProvider interface { + // Authenticate 验证服务是否可以注册 + Authenticate(service *ServiceInfo) error + // Authorize 验证客户端是否可以访问服务 + Authorize(serviceID, clientID string) error + // GenerateToken 生成认证令牌 + GenerateToken(service *ServiceInfo) (string, error) + // ValidateToken 验证认证令牌 + ValidateToken(token string) (*ServiceInfo, error) +} + +// Gateway 代理网关服务端接口 +// 运行在代理网关上,负责接收服务注册并暴露服务 +type Gateway interface { + // Start 启动网关 + Start(ctx context.Context) error + // Stop 停止网关 + Stop(ctx context.Context) error + // Services 获取所有注册的服务 + Services() []*ServiceInfo + // GetService 获取指定服务 + GetService(name string) (*ServiceInfo, error) + // Status 获取网关状态 + Status() GatewayStatus + // Forward 转发请求到指定服务 + Forward(ctx context.Context, serviceName string, endpointType EndpointType, conn net.Conn) error + // SetAuthProvider 设置认证提供者 + SetAuthProvider(auth AuthProvider) +} + +// GatewayStatus 网关状态 +type GatewayStatus int + +const ( + // GatewayStatusStopped 网关已停止 + GatewayStatusStopped GatewayStatus = iota + // GatewayStatusStarting 网关启动中 + GatewayStatusStarting + // GatewayStatusRunning 网关运行中 + GatewayStatusRunning + // GatewayStatusStopping 网关停止中 + GatewayStatusStopping +) + +// String 返回状态的字符串表示 +func (s GatewayStatus) String() string { + switch s { + case GatewayStatusStopped: + return "stopped" + case GatewayStatusStarting: + return "starting" + case GatewayStatusRunning: + return "running" + case GatewayStatusStopping: + return "stopping" + default: + return "unknown" + } +} + +// GatewayStatusInfo 网关状态信息 +type GatewayStatusInfo struct { + // Running 是否运行中 + Running bool `json:"running"` + // ListenAddr 监听地址 + ListenAddr string `json:"listen_addr"` + // Transport 使用的传输协议 + Transport string `json:"transport"` + // ServiceCount 注册的服务数量 + ServiceCount int `json:"service_count"` + // ConnectionCount 当前连接数 + ConnectionCount int `json:"connection_count"` + // StartedAt 启动时间 + StartedAt time.Time `json:"started_at,omitempty"` +} + +// Message 通信消息(类似 JSON-RPC) +type Message struct { + // Type 消息类型/方法 + Type MessageType `json:"type"` + // ID 消息ID,用于请求-响应匹配 + ID string `json:"id,omitempty"` + // Payload 消息负载(JSON 编码的具体数据) + Payload []byte `json:"payload,omitempty"` + // Error 错误信息 + Error string `json:"error,omitempty"` +} + +// MessageType 消息类型 +type MessageType uint8 + +const ( + // MessageTypeRegister 服务注册 + MessageTypeRegister MessageType = iota + 1 + // MessageTypeDeregister 服务注销 + MessageTypeDeregister + // MessageTypeHeartbeat 心跳 + MessageTypeHeartbeat + // MessageTypeRequest 请求 + MessageTypeRequest + // MessageTypeResponse 响应 + MessageTypeResponse + // MessageTypeStream 流数据 + MessageTypeStream + // MessageTypeAck 确认 + MessageTypeAck + // MessageTypeError 错误 + MessageTypeError + // MessageTypeHTTPRequest HTTP请求转发 + MessageTypeHTTPRequest + // MessageTypeGRPCRequest gRPC请求转发 + MessageTypeGRPCRequest + // MessageTypeDebugRequest Debug请求转发 + MessageTypeDebugRequest +) + +// RequestMeta 请求元数据 +type RequestMeta struct { + // ServiceID 目标服务ID + ServiceID string `json:"service_id"` + // EndpointType 端点类型 + EndpointType EndpointType `json:"endpoint_type"` + // Path 请求路径 + Path string `json:"path"` + // Method HTTP方法(仅HTTP端点) + Method string `json:"method,omitempty"` + // Headers 请求头 + Headers map[string]string `json:"headers,omitempty"` +} diff --git a/core/tunnel/yamux/yamux.go b/core/tunnel/yamux/yamux.go new file mode 100644 index 000000000..b4cc0ba34 --- /dev/null +++ b/core/tunnel/yamux/yamux.go @@ -0,0 +1,169 @@ +package yamux + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "sync" + "time" + + "github.com/libp2p/go-yamux/v5" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +func init() { + tunnel.RegisterTransport(tunnel.TransportYamux, NewTransport) +} + +// NewTransport creates a new yamux transport +func NewTransport(opts *tunnel.TransportOptions) (tunnel.Transport, error) { + return &yamuxTransport{opts: opts}, nil +} + +type yamuxTransport struct { + opts *tunnel.TransportOptions +} + +func (t *yamuxTransport) Name() string { + return tunnel.TransportYamux +} + +func (t *yamuxTransport) Dial(ctx context.Context, addr string) (tunnel.Session, error) { + dialer := &net.Dialer{Timeout: 30 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + + if t.opts != nil && t.opts.EnableTLS { + tlsConfig := &tls.Config{InsecureSkipVerify: t.opts.Insecure} + conn = tls.Client(conn, tlsConfig) + } + + session, err := yamux.Client(conn, t.buildConfig(), nil) + if err != nil { + if closeErr := conn.Close(); closeErr != nil { + return nil, fmt.Errorf("close conn failed: %w (original: %v)", closeErr, err) + } + return nil, err + } + + return &yamuxSession{session: session, conn: conn}, nil +} + +func (t *yamuxTransport) Listen(ctx context.Context, addr string) (tunnel.Listener, error) { + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + + if t.opts != nil && t.opts.EnableTLS { + cert, err := tls.LoadX509KeyPair(t.opts.CertFile, t.opts.KeyFile) + if err != nil { + if closeErr := ln.Close(); closeErr != nil { + return nil, fmt.Errorf("close listener failed: %w (original: %v)", closeErr, err) + } + return nil, err + } + ln = tls.NewListener(ln, &tls.Config{Certificates: []tls.Certificate{cert}}) + } + + return &yamuxListener{listener: ln, transport: t}, nil +} + +func (t *yamuxTransport) buildConfig() *yamux.Config { + cfg := yamux.DefaultConfig() + if t.opts != nil { + if t.opts.MaxStreams > 0 { + cfg.AcceptBacklog = t.opts.MaxStreams + cfg.MaxIncomingStreams = uint32(t.opts.MaxStreams) + } + if t.opts.KeepAliveInterval > 0 { + cfg.KeepAliveInterval = time.Duration(t.opts.KeepAliveInterval) * time.Second + } + if t.opts.ConnectionWriteTimeout > 0 { + cfg.ConnectionWriteTimeout = time.Duration(t.opts.ConnectionWriteTimeout) * time.Second + } + } + return cfg +} + +type yamuxListener struct { + listener net.Listener + transport *yamuxTransport +} + +func (l *yamuxListener) Accept() (tunnel.Session, error) { + conn, err := l.listener.Accept() + if err != nil { + return nil, err + } + session, err := yamux.Server(conn, l.transport.buildConfig(), nil) + if err != nil { + if closeErr := conn.Close(); closeErr != nil { + return nil, fmt.Errorf("close conn failed: %w (original: %v)", closeErr, err) + } + return nil, err + } + return &yamuxSession{session: session, conn: conn}, nil +} + +func (l *yamuxListener) Close() error { return l.listener.Close() } +func (l *yamuxListener) Addr() net.Addr { return l.listener.Addr() } + +type yamuxSession struct { + session *yamux.Session + conn net.Conn + mu sync.Mutex +} + +// Open 打开一个新的流 +func (s *yamuxSession) Open(ctx context.Context) (tunnel.Stream, error) { + stream, err := s.session.Open(ctx) + if err != nil { + return nil, err + } + + return &yamuxStream{stream: stream}, nil +} + +// OpenWithPriority 打开指定优先级的流(1-10,1最高) +func (s *yamuxSession) OpenWithPriority(ctx context.Context, priority int) (tunnel.Stream, error) { + // yamux 不支持优先级,直接调用 Open + return s.Open(ctx) +} + +func (s *yamuxSession) Accept() (tunnel.Stream, error) { + stream, err := s.session.AcceptStream() + if err != nil { + return nil, err + } + return &yamuxStream{stream: stream}, nil +} + +func (s *yamuxSession) Close() error { return s.session.Close() } +func (s *yamuxSession) IsClosed() bool { return s.session.IsClosed() } +func (s *yamuxSession) NumStreams() int { return s.session.NumStreams() } +func (s *yamuxSession) LocalAddr() net.Addr { return s.conn.LocalAddr() } +func (s *yamuxSession) RemoteAddr() net.Addr { return s.conn.RemoteAddr() } + +type yamuxStream struct { + stream net.Conn +} + +func (s *yamuxStream) Read(p []byte) (int, error) { return s.stream.Read(p) } +func (s *yamuxStream) Write(p []byte) (int, error) { return s.stream.Write(p) } +func (s *yamuxStream) Close() error { return s.stream.Close() } +func (s *yamuxStream) LocalAddr() net.Addr { return s.stream.LocalAddr() } +func (s *yamuxStream) RemoteAddr() net.Addr { return s.stream.RemoteAddr() } +func (s *yamuxStream) SetDeadline(t time.Time) error { return s.stream.SetDeadline(t) } +func (s *yamuxStream) SetReadDeadline(t time.Time) error { return s.stream.SetReadDeadline(t) } +func (s *yamuxStream) SetWriteDeadline(t time.Time) error { return s.stream.SetWriteDeadline(t) } + +// Priority 获取流优先级 +func (s *yamuxStream) Priority() int { + // yamux 不支持优先级,返回默认值 + return 5 +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..6dae8b392 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# Lava 文档总览 + +这套文档以当前仓库实现为准,重点覆盖: + +- 架构与运行流程 +- 设计原则与核心抽象 +- 各模块职责与入口 +- CLI 使用方式 + +## 推荐阅读顺序 + +1. `architecture-v2.md`:先建立全局认知 +2. `design-v2.md`:理解抽象与设计取舍 +3. `modules/README.md`:按目录快速定位模块 +4. `lava-command.md`:查命令参数与示例 + +## 文档地图 + +- 架构:`architecture-v2.md` +- 设计:`design-v2.md` +- 命令:`lava-command.md` +- 模块:`modules/README.md` + - `modules/core.md` + - `modules/servers.md` + - `modules/clients.md` + - `modules/pkg.md` + - `modules/cmds.md` + - `modules/lava.md` + - `modules/internal.md` + +## 约定说明 + +- 文档中的路径均为仓库相对路径。 +- 命令示例默认在仓库根目录执行。 +- 若文档与代码不一致,以代码为准,欢迎提交 PR 修正文档。 diff --git a/docs/architecture-v2.md b/docs/architecture-v2.md new file mode 100644 index 000000000..66836a68c --- /dev/null +++ b/docs/architecture-v2.md @@ -0,0 +1,157 @@ +# Lava 架构文档(v2) + +> 本文档描述当前仓库的实际分层与关键运行流程,重点面向开发与维护。 + +## 1. 分层视图 + +```mermaid +flowchart TD + subgraph Entry[入口层] + MAIN[main.go] + BUILDER[core/lavabuilder] + end + + subgraph Cmd[命令层] + CMDS[cmds/*] + end + + subgraph Service[服务层] + HTTPS[servers/https] + GRPCS[servers/grpcs] + TUNNEL[core/tunnel] + end + + subgraph Core[核心能力层] + SUP[core/supervisor] + DBG[core/debug] + LOG[core/logging] + MET[core/metrics] + TRC[core/tracing] + SCHED[core/scheduler] + DISC[core/discovery] + end + + subgraph Infra[基础设施层] + GW[pkg/gateway] + HTTPU[pkg/httputil] + GRPCU[pkg/grpcutil] + NETU[pkg/netutil] + end + + MAIN --> CMDS + BUILDER --> CMDS + CMDS --> HTTPS + CMDS --> GRPCS + CMDS --> TUNNEL + HTTPS --> SUP + GRPCS --> SUP + TUNNEL --> SUP + HTTPS --> DBG + GRPCS --> DBG + HTTPS --> LOG + GRPCS --> LOG + HTTPS --> MET + GRPCS --> MET + HTTPS --> TRC + GRPCS --> TRC + GRPCS --> GW + HTTPS --> HTTPU + GRPCS --> GRPCU + TUNNEL --> NETU + SCHED --> SUP + DISC --> SUP +``` + +## 2. 启动流程 + +Lava 当前存在两套常见入口: + +1. `main.go`:偏工具化 CLI(已接入 `watch/curl/tunnel/fileserver/devproxy`) +2. `core/lavabuilder.Run`:偏 DI 装配入口(注入 `version/health/dep/grpc/http/cron/tunnel` 等命令) + +```mermaid +sequenceDiagram + participant User as 用户 + participant Main as main.go + participant Cmd as cmds/* + participant Sup as supervisor.Manager + participant Svc as 服务(https/grpcs/tunnel) + + User->>Main: 执行 lava + Main->>Cmd: 解析并进入命令处理 + Cmd->>Sup: 构建/获取 Manager + Sup->>Svc: Add + Run + Svc-->>Sup: 生命周期与状态上报 + Sup-->>User: 运行日志/错误输出 +``` + +## 3. HTTP / gRPC 请求路径 + +### 3.1 HTTP 服务器(`servers/https`) + +```mermaid +sequenceDiagram + participant Client as HTTP Client + participant Fiber as Fiber App + participant Mid as Middlewares + participant Router as HttpRouter + participant Handler as 业务处理器 + + Client->>Fiber: HTTP Request + Fiber->>Mid: serviceinfo/metric/accesslog/recovery + Mid->>Router: 路由匹配(Prefix) + Router->>Handler: 处理业务 + Handler-->>Client: HTTP Response +``` + +### 3.2 gRPC 服务器(`servers/grpcs`) + +```mermaid +sequenceDiagram + participant Client as gRPC Client + participant GrpcSrv as grpc.Server + participant Intc as Unary/Stream Interceptor + participant Service as GrpcRouter + participant Gateway as pkg/gateway + participant HttpClient as HTTP Client + + Client->>GrpcSrv: gRPC 调用 + GrpcSrv->>Intc: 中间件链 + Intc->>Service: 业务处理 + Service-->>Client: gRPC 响应 + + HttpClient->>Gateway: HTTP/JSON + Gateway->>Intc: 转换并复用拦截链 + Intc->>Service: 调用 gRPC 服务 + Service-->>HttpClient: JSON 响应 +``` + +## 4. Tunnel 反向连接流程 + +`core/tunnel` 使用 Agent 主动连接 Gateway 的模式,适合内网服务暴露与远程调试。 + +```mermaid +sequenceDiagram + participant Agent as Tunnel Agent + participant Gw as Tunnel Gateway + participant Ext as External Client + participant Local as Local Service + + Agent->>Gw: Connect (:7007) + Agent->>Gw: Register(service/endpoints) + Ext->>Gw: HTTP/gRPC/Debug 请求 + Gw->>Agent: 转发请求 + Agent->>Local: 本地调用 + Local-->>Agent: 响应 + Agent-->>Gw: 返回 + Gw-->>Ext: 输出响应 +``` + +## 5. 目录与模块锚点 + +- 入口:`main.go`、`core/lavabuilder/builder.go` +- 服务:`servers/https`、`servers/grpcs` +- 核心:`core/supervisor`、`core/debug`、`core/tunnel` +- 组件:`clients/*`、`pkg/*`、`lava/*` + +详细模块信息见:`docs/modules/README.md`。 diff --git a/docs/copilot-skills.md b/docs/copilot-skills.md new file mode 100644 index 000000000..c2284bb78 --- /dev/null +++ b/docs/copilot-skills.md @@ -0,0 +1,573 @@ +# GitHub Copilot Skills for Lava Project + +## 1. Copilot 基础技能 + +### 1.1 代码补全 + +**功能说明**:GitHub Copilot 可以根据上下文自动补全代码,包括函数、变量、表达式等。 + +**使用技巧**: +- 在编写代码时,只需输入函数名或变量名的前几个字符,Copilot 会自动补全 +- 在编写注释时,Copilot 会根据注释内容生成相应的代码 +- 使用 `Tab` 键接受建议,`Esc` 键拒绝建议 + +**示例**: + +```go +// 输入注释 +// Get user by ID +func GetUser(id string) (*User, error) { + // Copilot 会自动生成函数体 +} + +// 输入函数名前缀 +func GetU // Copilot 会补全为 GetUser +``` + +### 1.2 代码生成 + +**功能说明**:GitHub Copilot 可以根据自然语言描述生成完整的代码块。 + +**使用技巧**: +- 使用详细的注释描述你想要的功能 +- 包括输入参数、返回值、处理逻辑等信息 +- Copilot 会根据注释生成相应的代码 + +**示例**: + +```go +// Generate a function that gets a user by ID from the database +// Parameters: +// id: user ID +// Returns: +// *User: user object +// error: error if any +func GetUserFromDatabase(id string) (*User, error) { + // Copilot 会生成完整的函数实现 +} +``` + +### 1.3 代码重构 + +**功能说明**:GitHub Copilot 可以帮助重构代码,包括提取函数、重命名变量、优化代码结构等。 + +**使用技巧**: +- 选择要重构的代码块 +- 使用 `Ctrl+I` (Windows/Linux) 或 `Cmd+I` (macOS) 打开 Copilot 面板 +- 输入重构指令,如 "Extract this into a function" + +**示例**: + +```go +// 选择以下代码块 +if err != nil { + log.Printf("Error: %v", err) + return nil, err +} + +// 输入指令 "Extract this into a function" +// Copilot 会生成错误处理函数 +func handleError(err error) error { + log.Printf("Error: %v", err) + return err +} +``` + +## 2. Copilot 高级技能 + +### 2.1 测试生成 + +**功能说明**:GitHub Copilot 可以根据现有代码生成相应的测试代码。 + +**使用技巧**: +- 打开测试文件(如 `user_test.go`) +- 输入测试函数的注释或函数名 +- Copilot 会生成完整的测试代码 + +**示例**: + +```go +// Test GetUser function +func TestGetUser(t *testing.T) { + // Copilot 会生成测试代码 +} +``` + +### 2.2 文档生成 + +**功能说明**:GitHub Copilot 可以根据代码生成相应的文档。 + +**使用技巧**: +- 在函数或类型定义前输入注释 +- Copilot 会生成详细的文档注释 + +**示例**: + +```go +// Copilot 会为以下函数生成文档注释 +func GetUser(id string) (*User, error) { + // 函数实现 +} +``` + +### 2.3 代码解释 + +**功能说明**:GitHub Copilot 可以解释现有代码的功能和逻辑。 + +**使用技巧**: +- 选择要解释的代码块 +- 使用 `Ctrl+I` (Windows/Linux) 或 `Cmd+I` (macOS) 打开 Copilot 面板 +- 输入指令 "Explain this code" + +**示例**: + +```go +// 选择以下代码 +func GetUser(id string) (*User, error) { + user, err := db.Query("SELECT * FROM users WHERE id = ?", id) + if err != nil { + return nil, err + } + return user, nil +} + +// 输入指令 "Explain this code" +// Copilot 会解释代码功能 +``` + +## 3. Lava 项目特定技能 + +### 3.1 服务创建 + +**功能说明**:在 Lava 项目中,Copilot 可以帮助创建符合项目架构的服务。 + +**使用技巧**: +- 输入服务创建的注释 +- 包括服务类型、依赖项、实现逻辑等信息 +- Copilot 会生成符合 Lava 架构的服务代码 + +**示例**: + +```go +// Create a new HTTP service with user routes +// Includes: +// - UserRouter with GetUsers and GetUser methods +// - Service registration with lavabuilder +func NewUserService() lava.Service { + // Copilot 会生成完整的服务实现 +} +``` + +### 3.2 中间件创建 + +**功能说明**:在 Lava 项目中,Copilot 可以帮助创建符合项目架构的中间件。 + +**使用技巧**: +- 输入中间件创建的注释 +- 包括中间件功能、实现逻辑等信息 +- Copilot 会生成符合 Lava 架构的中间件代码 + +**示例**: + +```go +// Create a logging middleware for HTTP requests +// Includes: +// - Logs request method, path, and duration +// - Implements lava.Middleware interface +func NewLoggingMiddleware() lava.Middleware { + // Copilot 会生成完整的中间件实现 +} +``` + +### 3.3 配置管理 + +**功能说明**:在 Lava 项目中,Copilot 可以帮助创建符合项目架构的配置管理代码。 + +**使用技巧**: +- 输入配置管理的注释 +- 包括配置结构、加载逻辑等信息 +- Copilot 会生成符合 Lava 架构的配置管理代码 + +**示例**: + +```go +// Create a configuration struct for user service +// Includes: +// - Database connection string +// - API port +// - Log level +func NewUserConfig() *UserConfig { + // Copilot 会生成完整的配置结构和加载逻辑 +} +``` + +### 3.4 gRPC 服务创建 + +**功能说明**:在 Lava 项目中,Copilot 可以帮助创建符合项目架构的 gRPC 服务。 + +**使用技巧**: +- 输入 gRPC 服务创建的注释 +- 包括服务定义、方法实现等信息 +- Copilot 会生成符合 Lava 架构的 gRPC 服务代码 + +**示例**: + +```go +// Create a gRPC service for user management +// Includes: +// - UserService with GetUser and ListUsers methods +// - Service registration with grpcs server +func NewUserGrpcService() lava.GrpcRouter { + // Copilot 会生成完整的 gRPC 服务实现 +} +``` + +## 4. Copilot 与 Lava 工具集成 + +### 4.1 与 Task 集成 + +**功能说明**:GitHub Copilot 可以帮助创建和修改 Task 配置文件。 + +**使用技巧**: +- 在 `taskfile.yml` 文件中,输入任务的名称和描述 +- Copilot 会生成相应的任务配置 + +**示例**: + +```yaml +# Add a new task for running integration tests +# Includes: +# - Runs integration tests with race detection +# - Generates coverage report +integration-test: + # Copilot 会生成完整的任务配置 +``` + +### 4.2 与 Protobuf 集成 + +**功能说明**:GitHub Copilot 可以帮助创建和修改 Protobuf 文件。 + +**使用技巧**: +- 在 `.proto` 文件中,输入服务或消息的定义 +- Copilot 会生成相应的 Protobuf 代码 + +**示例**: + +```protobuf +// Define a user service with GetUser and ListUsers methods +// Includes: +// - User message with id, name, and email fields +// - GetUserRequest and GetUserResponse messages +// - ListUsersRequest and ListUsersResponse messages +service UserService { + // Copilot 会生成完整的服务定义 +} +``` + +### 4.3 与 Docker 集成 + +**功能说明**:GitHub Copilot 可以帮助创建和修改 Docker 配置文件。 + +**使用技巧**: +- 在 `Dockerfile` 文件中,输入基础镜像和构建步骤 +- Copilot 会生成相应的 Docker 配置 + +**示例**: + +```dockerfile +# Create a Dockerfile for lava service +# Includes: +# - Uses golang:1.25 as base image +# - Builds the service +# - Runs the service +FROM golang:1.25-alpine AS builder +# Copilot 会生成完整的 Dockerfile +``` + +## 5. Copilot 最佳实践 + +### 5.1 编写清晰的注释 + +**功能说明**:清晰的注释可以帮助 Copilot 更好地理解你的意图,生成更准确的代码。 + +**最佳实践**: +- 使用详细的注释描述你想要的功能 +- 包括输入参数、返回值、处理逻辑等信息 +- 使用自然语言,避免使用过于技术性的术语 + +**示例**: + +```go +// Good: Detailed comment +// Get user by ID from the database +// Parameters: +// id: user ID +// Returns: +// *User: user object +// error: error if any +func GetUser(id string) (*User, error) { + // Copilot 会生成更准确的代码 +} + +// Bad: Vague comment +// Get user +func GetUser(id string) (*User, error) { + // Copilot 可能生成不准确的代码 +} +``` + +### 5.2 使用类型提示 + +**功能说明**:明确的类型提示可以帮助 Copilot 生成更准确的代码。 + +**最佳实践**: +- 为函数参数和返回值指定明确的类型 +- 为变量指定明确的类型 +- 使用结构体和接口定义数据模型 + +**示例**: + +```go +// Good: With type hints +func GetUser(id string) (*User, error) { + // Copilot 会生成更准确的代码 +} + +// Bad: Without type hints +func GetUser(id) { + // Copilot 可能生成不准确的代码 +} +``` + +### 5.3 分步骤生成代码 + +**功能说明**:复杂的功能可以分步骤生成,每一步生成一部分代码,然后再生成下一部分。 + +**最佳实践**: +- 先生成函数签名和注释 +- 然后生成函数体的框架 +- 最后生成具体的实现细节 + +**示例**: + +```go +// Step 1: Generate function signature +func GetUser(id string) (*User, error) { +} + +// Step 2: Generate function body framework +func GetUser(id string) (*User, error) { + // Get user from database + user, err := db.GetUser(id) + if err != nil { + return nil, err + } + return user, nil +} + +// Step 3: Generate detailed implementation +func GetUser(id string) (*User, error) { + // Validate ID + if id == "" { + return nil, errors.New("invalid user ID") + } + + // Get user from database + user, err := db.Query("SELECT * FROM users WHERE id = ?", id) + if err != nil { + return nil, errors.Wrap(err, "failed to get user") + } + + // Check if user exists + if user == nil { + return nil, errors.New("user not found") + } + + return user, nil +} +``` + +### 5.4 验证生成的代码 + +**功能说明**:生成的代码可能存在错误或不符合项目要求,需要验证和修改。 + +**最佳实践**: +- 检查生成的代码是否符合项目的编码风格 +- 检查生成的代码是否符合项目的架构要求 +- 检查生成的代码是否存在语法错误或逻辑错误 +- 运行测试验证生成的代码是否正确 + +**示例**: + +```go +// Generated code +func GetUser(id string) (*User, error) { + user, err := db.GetUser(id) + if err != nil { + return nil, err + } + return user, nil +} + +// After validation and modification +func GetUser(id string) (*User, error) { + if id == "" { + return nil, errors.New("invalid user ID") + } + + user, err := db.Query("SELECT * FROM users WHERE id = ?", id) + if err != nil { + return nil, errors.Wrap(err, "failed to get user") + } + + if user == nil { + return nil, errors.New("user not found") + } + + return user, nil +} +``` + +## 6. Copilot 快捷键 + +### 6.1 基本快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Tab` | 接受建议 | +| `Esc` | 拒绝建议 | +| `Ctrl+Enter` (Windows/Linux) 或 `Cmd+Enter` (macOS) | 打开 Copilot 面板 | +| `Ctrl+I` (Windows/Linux) 或 `Cmd+I` (macOS) | 显示内联建议 | + +### 6.2 高级快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Ctrl+Shift+P` (Windows/Linux) 或 `Cmd+Shift+P` (macOS) | 打开命令面板 | +| `Ctrl+K Ctrl+I` (Windows/Linux) 或 `Cmd+K Cmd+I` (macOS) | 显示代码提示 | +| `Alt+Shift+F` (Windows/Linux) 或 `Option+Shift+F` (macOS) | 格式化代码 | + +## 7. Copilot 常见问题 + +### 7.1 建议质量不高 + +**问题**:Copilot 生成的建议质量不高,不符合项目要求。 + +**解决方案**: +- 提供更详细的注释和上下文 +- 使用更具体的变量名和函数名 +- 分步骤生成代码 +- 参考项目中已有的代码风格和架构 + +### 7.2 建议与现有代码冲突 + +**问题**:Copilot 生成的建议与项目中已有的代码冲突。 + +**解决方案**: +- 检查项目中已有的代码和架构 +- 提供更具体的注释说明你的需求 +- 手动修改生成的代码以适应项目要求 + +### 7.3 建议速度慢 + +**问题**:Copilot 生成建议的速度很慢。 + +**解决方案**: +- 确保网络连接稳定 +- 减少同时打开的文件数量 +- 减少代码文件的大小 +- 分步骤生成代码,每一步生成一部分 + +### 7.4 建议不相关 + +**问题**:Copilot 生成的建议与当前上下文不相关。 + +**解决方案**: +- 提供更详细的注释和上下文 +- 明确指定你想要的功能和实现方式 +- 参考项目中已有的代码风格和架构 +- 手动修改生成的代码以适应项目要求 + +## 8. Copilot 与 Lava 项目集成 + +### 8.1 了解项目结构 + +**功能说明**:了解 Lava 项目的结构可以帮助 Copilot 生成更符合项目要求的代码。 + +**最佳实践**: +- 熟悉项目的目录结构和文件组织 +- 了解项目的核心模块和功能 +- 参考项目中已有的代码风格和架构 + +**项目结构**: + +``` +├── clients/ # Client libraries +├── cmds/ # Command-line tools +├── core/ # Core modules +├── docs/ # Documentation +├── internal/ # Internal implementation +├── lava/ # Public interfaces +├── pkg/ # Public packages +├── proto/ # Protobuf definitions +├── servers/ # Server implementations +└── tools/ # Development tools +``` + +### 8.2 参考现有代码 + +**功能说明**:参考项目中已有的代码可以帮助 Copilot 生成更符合项目要求的代码。 + +**最佳实践**: +- 查看项目中已有的类似功能的代码 +- 了解项目中使用的设计模式和架构 +- 参考项目中已有的命名约定和编码风格 + +**示例**: + +```go +// Reference existing code in the project +// For example, look at how other services are implemented +// Then ask Copilot to generate similar code +``` + +### 8.3 遵循项目规范 + +**功能说明**:遵循项目的规范可以帮助 Copilot 生成更符合项目要求的代码。 + +**最佳实践**: +- 遵循项目的命名约定和编码风格 +- 遵循项目的架构要求和设计模式 +- 使用项目中已有的工具和库 + +**示例**: + +```go +// Follow project conventions +// For example, use the same error handling pattern as other parts of the project +func GetUser(id string) (*User, error) { + user, err := db.GetUser(id) + if err != nil { + return nil, errors.Wrap(err, "failed to get user") + } + return user, nil +} +``` + +## 9. 总结 + +GitHub Copilot 是一个强大的 AI 辅助开发工具,可以帮助你在 Lava 项目中更高效地编写代码。通过使用本文档中介绍的技能和技巧,你可以: + +1. **提高开发效率**:自动补全和生成代码,减少重复工作 +2. **改善代码质量**:生成符合项目规范的代码,减少错误 +3. **学习项目架构**:通过参考现有代码和生成的建议,了解项目的架构和设计模式 +4. **探索新功能**:快速原型设计和探索新功能,加速开发过程 + +记住,GitHub Copilot 是一个辅助工具,不是替代品。你仍然需要: + +1. **验证生成的代码**:检查生成的代码是否符合项目要求和最佳实践 +2. **理解生成的代码**:确保你理解生成的代码的功能和逻辑 +3. **修改生成的代码**:根据项目要求和具体情况修改生成的代码 +4. **测试生成的代码**:运行测试验证生成的代码是否正确 + +通过合理使用 GitHub Copilot,你可以在 Lava 项目中更高效地开发,同时保持代码的质量和可维护性。 \ No newline at end of file diff --git a/docs/design-v2.md b/docs/design-v2.md new file mode 100644 index 000000000..95f51cd86 --- /dev/null +++ b/docs/design-v2.md @@ -0,0 +1,129 @@ +# Lava 设计文档(v2) + +## 1. 设计目标 + +Lava 在设计上聚焦三件事: + +1. **统一抽象**:命令、服务、路由、中间件使用统一接口语义。 +2. **可运维**:默认带调试、日志、指标、生命周期管理能力。 +3. **可扩展**:通过 `core/*` 与 `pkg/*` 的分层,把业务与基础设施解耦。 + +## 2. 核心抽象 + +### 2.1 中间件抽象(`lava/middleware.go`) + +```go +type HandlerFunc func(ctx context.Context, req Request) (Response, error) + +type Middleware interface { + String() string + Middleware(next HandlerFunc) HandlerFunc +} +``` + +这是一种“函数包裹函数”的链式模型,支持同一语义在 HTTP/gRPC/Client 场景复用。 + +### 2.2 路由抽象(`lava/router.go`) + +```go +type HttpRouter interface { + Middlewares() []Middleware + Router(router fiber.Router) + Prefix() string +} + +type GrpcRouter interface { + Middlewares() []Middleware + ServiceDesc() *grpc.ServiceDesc +} +``` + +设计价值:HTTP 与 gRPC 路由都具备“挂载 + 中间件 + 前缀/描述”统一风格。 + +### 2.3 服务抽象(`core/supervisor/types.go`) + +```go +type Service interface { + Name() string + Error() error + String() string + Serve(ctx context.Context) error + Metric() *Metric +} +``` + +`supervisor.Manager` 基于该接口实现生命周期托管、重启策略和状态观测。 + +## 3. 关键设计决策 + +### 3.1 Supervisor 负责“稳态运行” + +- 支持重启策略:`RestartAlways` / `RestartOnFailure` / `RestartNever` +- 支持窗口限流:`RestartWindow` + `MaxRestartsInWindow` +- 支持退避:`RestartDelay` -> `MaxRestartDelay`(按倍率增长) + +可用近似表达: + +$$ +delay_{n+1}=\min(delay_n\times backoff,\ maxDelay) +$$ + +### 3.2 Gateway 与 gRPC 服务同源注册 + +`servers/grpcs` 同时: + +- 向 `grpc.Server` 注册 `ServiceDesc` +- 向 `pkg/gateway.Mux` 注册 `ServiceDesc` + +这使 HTTP/JSON 与 gRPC 共享同一套服务定义,减少重复维护。 + +### 3.3 Debug 能力内建 + +- `servers/https` 与 `servers/grpcs` 默认挂载 `/debug` +- `vars.Register(...)` 暴露配置、路由、服务信息 + +## 4. 中间件执行流程 + +```mermaid +sequenceDiagram + participant Caller as 调用方 + participant Chain as lava.Chain + participant M1 as Middleware A + participant M2 as Middleware B + participant H as Handler + + Caller->>Chain: Handle(req) + Chain->>M1: Middleware(next) + M1->>M2: Middleware(next) + M2->>H: 执行 handler + H-->>M2: response + M2-->>M1: response + M1-->>Caller: response +``` + +## 5. 服务重启状态机(简化) + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> running: StartService / AutoStart + running --> stopped: StopService + running --> crashing: error + 可重启 + crashing --> running: backoff 后重启 + crashing --> failed: 超过阈值 + failed --> running: ResetService + StartService + stopped --> running: StartService +``` + +## 6. CLI 设计要点 + +- 根入口 `main.go` 偏开发工具集(watch/curl/tunnel/fileserver/devproxy) +- `core/lavabuilder.Run` 偏 DI 装配入口(更多服务型命令) + +因此文档需要明确“入口上下文”,避免命令清单混淆。 + +## 7. 文档与实现的一致性建议 + +1. 命令文档以 `main.go` 作为根入口真值。 +2. 接口文档优先引用 `lava/*.go` 与 `core/supervisor/types.go`。 +3. 流程图更新时,必须同步标注对应实现路径。 diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 000000000..e36edbd12 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,82 @@ +# Lava 开发指南 + +## 1. 开发环境 + +- Go `1.25+` +- Task +- `golangci-lint`(执行 `task lint` 需要) + +## 2. 仓库初始化 + +```bash +git clone https://github.com/pubgo/lava.git +cd lava +go mod tidy +``` + +## 3. 常用开发命令(以 `Taskfile.yml` 为准) + +| 命令 | 说明 | +| ----------------- | -------------------------------------- | +| `task info` | 输出版本与分支信息 | +| `task tools` | 安装常用开发工具 | +| `task generate` | 执行 `go generate ./internal/...` | +| `task proto:fmt` | proto 格式化 | +| `task proto:lint` | proto lint | +| `task proto:gen` | proto 依赖拉取并生成代码 | +| `task vet` | `go vet ./...` | +| `task test` | `go test -short -race -v ./... -cover` | +| `task lint` | `golangci-lint run --verbose ./...` | + +## 4. 推荐开发流程 + +1. 新建分支开发 +2. 小步提交(建议 `feat/fix/docs/refactor/test/chore` 前缀) +3. 每次提交前执行: + +```bash +task test +task lint +``` + +4. 涉及 proto 变更时执行: + +```bash +task proto:fmt +task proto:lint +task proto:gen +``` + +## 5. 命令入口说明 + +仓库当前存在两种入口: + +- 根入口:`main.go`(工具型命令) +- DI 入口:`core/lavabuilder.Run`(服务型命令装配) + +开发文档中的命令示例默认以根入口为准。 + +## 6. 文档维护规范 + +- 命令文档必须对齐 `main.go` 实际注册命令。 +- 接口文档必须对齐 `lava/*.go` 与 `core/supervisor/types.go`。 +- 涉及流程图更新时,需同步标注对应实现路径。 + +## 7. 常见问题 + +### Q1: `task build` / `task clean` 为什么不存在? + +当前根 `Taskfile.yml` 未定义这些任务,请以 `task -a` 输出为准。 + +### Q2: 为什么 `cmds/` 有些命令跑不出来? + +因为它们未接入 `main.go`。详见 `docs/modules/cmds.md`。 + +### Q3: 我该先看哪份文档? + +建议顺序: + +1. `docs/architecture-v2.md` +2. `docs/design-v2.md` +3. `docs/modules/README.md` +4. `docs/lava-command.md` diff --git a/docs/lava-command.md b/docs/lava-command.md new file mode 100644 index 000000000..aadc52cb2 --- /dev/null +++ b/docs/lava-command.md @@ -0,0 +1,166 @@ +# Lava 命令行文档(当前入口) + +> 本文档以仓库根入口 `main.go` 为准。 + +## 命令总览 + +| 命令 | 说明 | +| ----------------------- | -------------------------- | +| `lava watch` | 文件监听与自动执行命令 | +| `lava curl` | Gateway 调试客户端 | +| `lava tunnel gateway` | 启动 Tunnel Gateway | +| `lava fileserver ` | 本地目录静态文件服务 | +| `lava devproxy` | 本地开发代理(DNS + HTTP) | + +--- + +## 1. `lava watch` + +文件变化监听器。读取配置文件(按优先级): + +1. `.lava/lava.yaml` +2. `.lava.yaml` +3. `lava.yaml` + +若未找到配置,将使用内置默认 watcher。 + +### 核心字段 + +| 字段 | 说明 | +| ----------------- | -------------------------- | +| `name` | watcher 名称 | +| `directory` | 监听目录 | +| `patterns` | 匹配模式(支持 `!` 排除) | +| `commands` | 文件变更后执行命令列表 | +| `ignore` | 兼容字段,内部转为排除模式 | +| `ignore_patterns` | 兼容字段,内部转为排除模式 | +| `run_on_startup` | 启动即执行一次 | +| `timeout` | 单条命令超时(秒) | + +### 示例 + +```yaml +watch: + watchers: + - name: proto + directory: . + patterns: + - "**/*.proto" + - "!**/vendor" + commands: + - "protobuild gen" + timeout: 30 +``` + +--- + +## 2. `lava curl` + +面向 Gateway 的轻量客户端,支持: + +- operation 调用(如 `Service/Method`) +- 显式路径调用 +- Header/Query/Path 参数注入 +- `login` 子命令保存 token + +### 常用参数 + +| 参数 | 说明 | +| ------------------------ | ------------------------- | +| `--addr` | 网关地址 | +| `--prefix` | 网关前缀(默认 `/api`) | +| `--operation` | 指定 operation | +| `--path` | 显式路径 | +| `-X, --method` | 覆盖 HTTP 方法 | +| `-d, --data` | 请求体字符串 | +| `--data-file` | 从文件读取请求体 | +| `--stdin` | 从标准输入读取请求体 | +| `-H, --header key=value` | 追加请求头 | +| `-Q, --query key=value` | 追加 query | +| `-P, --param key=value` | 填充路径参数 | +| `--list` | 仅列出路由 | +| `--vars-name` | debug vars 中网关信息名称 | +| `--timeout` | 请求超时 | +| `-k, --insecure` | 跳过 TLS 校验 | +| `--raw` | 原样输出响应体 | + +### 登录子命令 + +- `lava curl login -t ` +- `lava curl login --stdin` +- `lava curl login --env`(读取 `LAVA_TOKEN`) + +Token 文件路径:`~/.lava/token`。 + +--- + +## 3. `lava tunnel gateway` + +启动 Tunnel Gateway。 + +### 默认配置 + +| 配置项 | 默认值 | 对应环境变量 | +| -------------- | ------- | -------------------- | +| 监听地址 | `:7007` | `TUNNEL_LISTEN_ADDR` | +| HTTP 代理端口 | `8888` | `TUNNEL_HTTP_PORT` | +| gRPC 代理端口 | `9999` | `TUNNEL_GRPC_PORT` | +| Debug 代理端口 | `6066` | `TUNNEL_DEBUG_PORT` | +| 管理界面端口 | `:6067` | `TUNNEL_ADMIN_ADDR` | + +命令会同时启动: + +- Gateway 服务 +- Supervisor 管理与 debug +- 独立 debug 管理页面(默认 `:6067`) + +--- + +## 4. `lava fileserver ` + +将指定目录作为静态文件服务对外暴露。 + +- 若未传 ``,默认使用当前工作目录。 +- 端口使用运行时 HTTP 端口(通常为 `running.HttpPort`)。 + +常见用途:快速预览构建产物、临时共享静态目录。 + +--- + +## 5. `lava devproxy` + +本地开发代理,提供: + +- DNS 解析(`*.lava` -> `127.0.0.1`) +- HTTP 反向代理(按子域名匹配路由) + +### 子命令 + +| 子命令 | 说明 | +| ----------- | --------------------- | +| `start` | 启动 devproxy | +| `install` | 安装系统集成(macOS) | +| `uninstall` | 卸载系统集成(macOS) | +| `routes` | 输出当前路由 | + +### 配置文件查找顺序 + +1. `.devproxy.json` +2. `.devproxy.yaml` +3. `.devproxy.yml` +4. `~/.devproxy.json` +5. `~/.devproxy.yaml` +6. `~/.devproxy.yml` + +### 默认端口 + +- DNS:`5353` +- HTTP:`8080` + +--- + +## 6. 说明:为何有些命令在代码里但跑不出来? + +仓库中还存在一些命令包(如 `config/health/http/grpc/cron/version`),但它们并未注册到根入口 `main.go`。如果你从 `main.go` 构建的二进制执行,这些命令不会出现。 + +详见:`docs/modules/cmds.md`。 diff --git a/docs/modules/README.md b/docs/modules/README.md new file mode 100644 index 000000000..107687d2d --- /dev/null +++ b/docs/modules/README.md @@ -0,0 +1,25 @@ +# 模块文档总览 + +本文档按目录组织 Lava 的模块职责,帮助你快速定位代码入口。 + +## 分册导航 + +- `core.md`:核心能力模块 +- `servers.md`:服务端实现模块 +- `clients.md`:客户端模块 +- `pkg.md`:公共组件模块 +- `cmds.md`:命令模块(含已接入/未接入) +- `lava.md`:接口抽象层 +- `internal.md`:内部实现层 + +## 目录关系图 + +```mermaid +flowchart LR + CORE[core/*] --> SERVERS[servers/*] + CORE --> CLIENTS[clients/*] + CORE --> PKG[pkg/*] + CMDS[cmds/*] --> CORE + LAVA[lava/* interfaces] --> SERVERS + LAVA --> CLIENTS +``` diff --git a/docs/modules/clients.md b/docs/modules/clients.md new file mode 100644 index 000000000..8aee69267 --- /dev/null +++ b/docs/modules/clients.md @@ -0,0 +1,32 @@ +# Clients 模块文档 + +`clients/*` 提供对下游服务调用能力,覆盖 gRPC 与 HTTP。 + +## 模块清单 + +| 模块 | 说明 | 关键入口 | +| --------------- | -------------------------------------------------- | ------------------------------ | +| `clients/grpcc` | gRPC 客户端封装(懒连接、健康检查、Invoke/Stream) | `clients/grpcc/client.go::New` | +| `clients/resty` | HTTP 客户端封装(中间件、重试、配置化) | `clients/resty/client.go::New` | + +## `grpcc` 要点 + +- 通过 `grpccconfig.Cfg` 管理连接参数 +- `Get()` 懒创建连接并缓存 +- 暴露 `Invoke` / `NewStream` / `Healthy` + +## `resty` 要点 + +- 默认挂载 serviceinfo/metric/accesslog/recovery 中间件 +- 支持重试策略(基于 `DefaultRetryCount/Interval`) +- 通过 `Request` + `Client.Do(ctx, req)` 执行调用 + +## 统一抽象关系 + +```mermaid +flowchart TD + MW[lava.Middleware] --> GRPCC[clients/grpcc] + MW --> RESTY[clients/resty] + GRPCC --> Downstream1[gRPC Service] + RESTY --> Downstream2[HTTP Service] +``` diff --git a/docs/modules/cmds.md b/docs/modules/cmds.md new file mode 100644 index 000000000..c5f1c9b06 --- /dev/null +++ b/docs/modules/cmds.md @@ -0,0 +1,44 @@ +# Cmds 模块文档 + +`cmds/*` 保存命令实现。由于仓库存在两种入口(`main.go` 与 `lavabuilder.Run`),命令接线状态分为“已接入”和“未接入”。 + +## 1) `main.go` 已接入命令 + +| 命令 | 路径 | +| ------------ | --------------------------- | +| `watch` | `cmds/watchcmd/cmd.go` | +| `curl` | `cmds/curlcmd/cmd.go` | +| `tunnel` | `cmds/tunnelcmd/cmd.go` | +| `fileserver` | `cmds/fileservercmd/cmd.go` | +| `devproxy` | `cmds/devproxycmd/cmd.go` | + +## 2) `cmds` 中存在但未接入 `main.go` + +| 命令包 | 实际 `Use` | +| -------------------- | ---------- | +| `cmds/configcmd` | `config` | +| `cmds/depcmd` | `dep` | +| `cmds/envcmd` | `envs` | +| `cmds/grpcservercmd` | `grpc` | +| `cmds/healthcmd` | `health` | +| `cmds/httpservercmd` | `http` | +| `cmds/schedulercmd` | `cron` | +| `cmds/versioncmd` | `version` | + +## 3) 常见维护点 + +1. 新增命令后,确认是否已在 `main.go` 注册。 +2. 如果命令只在 DI 场景使用,需在文档注明“入口限制”。 +3. 文档示例必须与 `Use` 字段一致(例如 `cron` vs `scheduler`)。 + +## 4) 命令执行路径(`main.go`) + +```mermaid +flowchart TD + User[用户输入 lava ...] --> Root[main.go redant.Command] + Root --> Watch[watch] + Root --> Curl[curl] + Root --> Tunnel[tunnel gateway] + Root --> FileServer[fileserver] + Root --> DevProxy[devproxy] +``` diff --git a/docs/modules/core.md b/docs/modules/core.md new file mode 100644 index 000000000..1e7509047 --- /dev/null +++ b/docs/modules/core.md @@ -0,0 +1,40 @@ +# Core 模块文档 + +`core/*` 承载框架级能力,是服务编排与运行时能力的基础层。 + +## 模块清单 + +| 模块 | 主要职责 | 关键文件 | +| ------------------ | -------------------------- | ----------------------------- | +| `core/supervisor` | 服务生命周期托管与重启策略 | `core/supervisor/manager.go` | +| `core/debug` | 调试路由聚合与挂载 | `core/debug/mux.go` | +| `core/scheduler` | 任务调度能力 | `core/scheduler/scheduler.go` | +| `core/tunnel` | 反向连接隧道与代理 | `core/tunnel/doc.go` | +| `core/logging` | 日志工厂与 logger 扩展 | `core/logging/factory.go` | +| `core/metrics` | 指标驱动与指标工厂 | `core/metrics/factory.go` | +| `core/tracing` | OpenTelemetry 初始化与追踪 | `core/tracing/telemetry.go` | +| `core/discovery` | 服务发现抽象(含 noop) | `core/discovery/noop.go` | +| `core/encoding` | 编解码器注册与选择 | `core/encoding/encoding.go` | +| `core/lavabuilder` | DI 初始化与命令装配入口 | `core/lavabuilder/builder.go` | +| `core/signals` | 系统信号转 context 取消 | `core/signals/signal.go` | +| `core/lifecycle` | 生命周期钩子抽象 | `core/lifecycle/lifecycle.go` | + +## 典型调用链 + +```mermaid +flowchart TD + Entry[main/lavabuilder] --> Sup[supervisor.Manager] + Sup --> SvcA[https service] + Sup --> SvcB[grpcs service] + Sup --> SvcC[tunnel gateway] + SvcA --> Debug["/debug"] + SvcB --> Debug + SvcA --> Obs[logging/metrics/tracing] + SvcB --> Obs +``` + +## 维护建议 + +- 任何服务型组件都尽量实现 `supervisor.Service`,避免自管生命周期。 +- 调试端点统一走 `core/debug`,避免分散挂载。 +- 新模块若涉及跨服务观测,优先接入 `metrics` / `tracing`。 diff --git a/docs/modules/internal.md b/docs/modules/internal.md new file mode 100644 index 000000000..df7e76fc4 --- /dev/null +++ b/docs/modules/internal.md @@ -0,0 +1,19 @@ +# Internal 模块(`internal/*`) + +`internal/*` 存放框架内部实现细节与示例,默认不作为对外稳定 API。 + +## 子目录概览 + +| 目录 | 职责 | +| ---------------------- | -------------------------------------------------- | +| `internal/configs` | 内部配置结构与默认值 | +| `internal/consts` | 内部常量 | +| `internal/logutil` | 日志辅助函数 | +| `internal/middlewares` | 内建中间件实现 | +| `internal/examples` | 示例工程(scheduler/tunnel/grpcweb/fileserver 等) | + +## 使用建议 + +- 业务项目避免直接依赖 `internal/*`。 +- 若内部能力被多处复用并趋于稳定,考虑上移到 `pkg/*` 或 `core/*`。 +- 文档示例可引用 `internal/examples/*`,但应说明其“示例性质”。 diff --git a/docs/modules/lava.md b/docs/modules/lava.md new file mode 100644 index 000000000..9c8017a15 --- /dev/null +++ b/docs/modules/lava.md @@ -0,0 +1,25 @@ +# Lava 接口层模块(`lava/*`) + +`lava/*` 定义跨模块共享的核心接口,是框架的抽象边界。 + +## 文件与职责 + +| 文件 | 职责 | +| -------------------- | ------------------------------------------ | +| `lava/middleware.go` | 中间件与链式组合抽象 | +| `lava/request.go` | 请求抽象接口 | +| `lava/response.go` | 响应抽象接口 | +| `lava/router.go` | HTTP/gRPC Router 接口定义 | +| `lava/server.go` | 通用 server/closer/listener/validator 接口 | + +## 关键价值 + +1. 降低模块耦合:`servers/*`、`clients/*` 通过接口协作。 +2. 保证扩展一致性:新增组件时优先对齐这层接口。 +3. 让中间件在不同协议场景下复用。 + +## 推荐实践 + +- 新增跨模块抽象时先评估是否应放入 `lava/*`。 +- 避免在接口层引入业务依赖。 +- 文档示例优先引用真实签名,防止“接口漂移”。 diff --git a/docs/modules/pkg.md b/docs/modules/pkg.md new file mode 100644 index 000000000..602ab4cb2 --- /dev/null +++ b/docs/modules/pkg.md @@ -0,0 +1,34 @@ +# Pkg 模块文档 + +`pkg/*` 放置可复用公共组件,既用于框架内部,也可供业务项目直接复用。 + +## 模块清单 + +| 模块 | 说明 | 关键文件 | +| ------------------ | ------------------------- | -------------------------------- | +| `pkg/gateway` | HTTP/JSON ↔ gRPC 协议转换 | `pkg/gateway/_doc.go` | +| `pkg/httputil` | Fiber/HTTP 适配与路径处理 | `pkg/httputil/fiber.go` | +| `pkg/grpcutil` | gRPC 元数据与调试辅助 | `pkg/grpcutil/util.go` | +| `pkg/grpcbuilder` | gRPC server 配置构建 | `pkg/grpcbuilder/config.go` | +| `pkg/fiberbuilder` | Fiber 配置构建 | `pkg/fiberbuilder/config.go` | +| `pkg/netutil` | 网络与地址工具 | `pkg/netutil/*.go` | +| `pkg/cliutil` | CLI 说明文本与示例拼接 | `pkg/cliutil/cmd.go` | +| `pkg/wsproxy` | WebSocket/HTTP 转发代理 | `pkg/wsproxy/websocket_proxy.go` | +| `pkg/wsbuilder` | WebSocket 构建辅助 | `pkg/wsbuilder/ws.go` | +| `pkg/k8sutil` | K8s 环境探测工具 | `pkg/k8sutil/util.go` | +| `pkg/proto` | protobuf 生成代码产物 | `pkg/proto/lavapbv1/*.pb.go` | + +## Gateway 位置说明 + +`pkg/gateway` 是协议转换核心: + +- 读取 gRPC `ServiceDesc` +- 生成 HTTP 路由映射 +- 负责请求编解码与错误映射 + +```mermaid +flowchart LR + HTTP[HTTP/JSON] --> GW[pkg/gateway] + GW --> GRPC[gRPC ServiceDesc] + GRPC --> BIZ[Service Impl] +``` diff --git a/docs/modules/servers.md b/docs/modules/servers.md new file mode 100644 index 000000000..11484b313 --- /dev/null +++ b/docs/modules/servers.md @@ -0,0 +1,37 @@ +# Servers 模块文档 + +`servers/*` 提供对外服务能力,当前主要包含 HTTP 与 gRPC 两类服务。 + +## 模块清单 + +| 模块 | 说明 | 关键入口 | +| --------------- | ------------------------------------------------ | ------------------------------ | +| `servers/https` | Fiber HTTP 服务封装,默认接入 debug 与基础中间件 | `servers/https/server.go::New` | +| `servers/grpcs` | gRPC + Gateway 一体化服务,支持 gRPC/HTTP 双栈 | `servers/grpcs/server.go::New` | + +## `servers/https` 要点 + +- 使用 Fiber 创建 HTTP 服务 +- 默认中间件:serviceinfo / metric / accesslog / recovery +- 默认挂载:`/debug` +- 通过 `lava.HttpRouter` 进行路由装配 + +## `servers/grpcs` 要点 + +- 基于 `grpc.Server` 注册服务 +- 同步注册到 `pkg/gateway.Mux`,支持 HTTP/JSON 调用 +- 支持 `GrpcRouter` 与 `GrpcHttpRouter` +- 通过 `vars.Register` 暴露路由与服务信息 + +## 处理流程 + +```mermaid +flowchart LR + Req1[HTTP Request] --> HTTPS[servers/https] + Req2[gRPC Request] --> GRPCS[servers/grpcs] + Req3[HTTP JSON] --> GW[pkg/gateway] + GW --> GRPCS + HTTPS --> Mid[Middlewares] + GRPCS --> Mid + Mid --> Biz[Business Handler] +``` diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 000000000..56b2cfb9a --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,112 @@ +# Lava 快速开始 + +本文档面向“先跑起来,再看源码”。 + +## 1. 环境准备 + +- Go `1.25+` +- Task + +macOS 示例: + +```bash +brew install go +brew install go-task/tap/go-task +``` + +## 2. 获取并初始化 + +```bash +git clone https://github.com/pubgo/lava.git +cd lava +go mod tidy +``` + +## 3. 基础质量检查 + +```bash +task test +task lint +``` + +## 4. 构建 CLI + +```bash +go build -o lava . +./lava +``` + +当前根入口可用命令: + +- `watch` +- `curl` +- `tunnel gateway` +- `fileserver ` +- `devproxy` + +## 5. 常用体验路径 + +### 5.1 本地文件服务 + +```bash +./lava fileserver . +``` + +然后访问输出日志中的本地地址。 + +### 5.2 文件变更监听 + +```bash +./lava watch +``` + +默认会尝试读取以下配置文件: + +1. `.lava/lava.yaml` +2. `.lava.yaml` +3. `lava.yaml` + +找不到时使用内置默认 watcher。 + +### 5.3 Tunnel Gateway + +```bash +./lava tunnel gateway +``` + +可通过环境变量覆盖端口和地址: + +- `TUNNEL_LISTEN_ADDR` +- `TUNNEL_HTTP_PORT` +- `TUNNEL_GRPC_PORT` +- `TUNNEL_DEBUG_PORT` +- `TUNNEL_ADMIN_ADDR` + +### 5.4 devproxy + +```bash +./lava devproxy start +``` + +macOS 可选安装系统集成: + +```bash +sudo ./lava devproxy install +``` + +## 6. Proto 工作流 + +```bash +task proto:fmt +task proto:lint +task proto:gen +``` + +相关配置:`protobuf.yaml`。 + +## 7. 下一步阅读 + +- 架构:`docs/architecture-v2.md` +- 设计:`docs/design-v2.md` +- 模块:`docs/modules/README.md` +- 命令:`docs/lava-command.md` diff --git a/docs/supervisor.md b/docs/supervisor.md new file mode 100644 index 000000000..b9f20b03d --- /dev/null +++ b/docs/supervisor.md @@ -0,0 +1,188 @@ +# Supervisor 服务管理模块 + +## 概述 +`core/supervisor` 提供统一的服务生命周期管理能力:注册服务、启动/停止/重启、自动重启策略、状态与指标查询,并配套调试 UI/API。 + +该模块适合在微服务进程内统一托管 HTTP/gRPC/后台任务等服务,实现可靠的启动顺序、失败恢复和运行可观测性。 + +## 核心概念 + +- **Manager**:服务管理器,维护服务集合与生命周期控制。 +- **Service**:被管理的服务,需实现 `Serve(ctx)` 与 `Metric()` 等接口。 +- **ServiceConfig**:服务级别配置(重启策略、退避、窗口期等)。 +- **Option**:函数式配置选项,便于按需覆盖默认配置。 +- **ServiceInfo**:服务运行态信息(重启次数、失败状态、窗口期统计等)。 + +## 快速开始 + +### 1) 定义服务 + +```go +import ( + "context" + "github.com/pubgo/lava/v2/core/supervisor" +) + +func mainTask(ctx context.Context) error { + // 你的业务逻辑 + <-ctx.Done() + return nil +} + +srv := supervisor.NewService("main-task", mainTask) +``` + +或实现 `Service` 接口: + +```go +type MyService struct{} + +func (s *MyService) Name() string { return "my-service" } +func (s *MyService) Error() error { return nil } +func (s *MyService) String() string { return s.Name() } +func (s *MyService) Metric() *supervisor.Metric { return &supervisor.Metric{Name: s.Name()} } +func (s *MyService) Serve(ctx context.Context) error { + <-ctx.Done() + return nil +} +``` + +### 2) 创建 Manager 并注册服务 + +```go +import ( + "context" + "github.com/pubgo/lava/v2/core/lifecycle" + "github.com/pubgo/lava/v2/core/supervisor" +) + +lc := lifecycle.New() +manager := supervisor.NewManager("app", lc) + +// 默认自动启动 +_ = manager.Add(srv) + +// 不自动启动,按需手动启动 +_ = manager.Add(srv, supervisor.WithAutoStart(false)) +``` + +### 3) 运行 Manager + +```go +ctx, cancel := context.WithCancel(context.Background()) + +defer cancel() +_ = manager.Run(ctx) // 启动并阻塞,直到 ctx 被取消 +``` + +## 主要 API + +### Manager + +```go +func NewManager(name string, lc lifecycle.Getter) *Manager +func Default(lc lifecycle.Getter) *Manager + +func (m *Manager) Add(srv Service, opts ...Option) error +func (m *Manager) AddWithConfig(srv Service, config ServiceConfig) error +func (m *Manager) Delete(name string) error +func (m *Manager) RemoveServices() error + +func (m *Manager) StartService(name string) error +func (m *Manager) StopService(name string) error +func (m *Manager) RestartService(name string) error +func (m *Manager) RestartServices() error +func (m *Manager) ResetService(name string) error + +func (m *Manager) GetServicesInfo() []*ServiceInfo +func (m *Manager) GetServiceInfo(name string) (*ServiceInfo, error) +func (m *Manager) Services() []Service + +func (m *Manager) Run(ctx context.Context) error +func (m *Manager) Serve(ctx context.Context) error +func (m *Manager) ServeBackground(ctx context.Context) <-chan error +``` + +### ServiceConfig 与 Option + +`ServiceConfig` 控制服务重启策略与退避行为。`Add` 会使用默认配置并应用 `Option`。 + +默认值: + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `AutoStart` | `true` | 是否自动启动 | +| `RestartPolicy` | `RestartAlways` | 重启策略 | +| `MaxRestarts` | `0` | 总重启次数上限,0 表示无限制 | +| `RestartDelay` | `1s` | 初始重启延迟 | +| `MaxRestartDelay` | `1m` | 最大重启延迟 | +| `RestartWindow` | `5m` | 重启计数窗口期 | +| `MaxRestartsInWindow` | `5` | 窗口期最大重启次数 | +| `BackoffMultiplier` | `2.0` | 退避倍数 | + +可用选项: + +```go +func WithAutoStart(autoStart bool) Option +func WithRestartPolicy(policy RestartPolicy) Option +func WithMaxRestarts(n int) Option +func WithRestartDelay(d time.Duration) Option +func WithBackoff(maxDelay time.Duration, multiplier float64) Option +func WithRestartWindow(window time.Duration, maxRestarts int) Option +``` + +示例: + +```go +_ = manager.Add( + srv, + supervisor.WithAutoStart(true), + supervisor.WithRestartPolicy(supervisor.RestartOnFailure), + supervisor.WithMaxRestarts(3), + supervisor.WithRestartDelay(2*time.Second), + supervisor.WithBackoff(30*time.Second, 1.5), + supervisor.WithRestartWindow(2*time.Minute, 5), +) +``` + +## 错误与重启控制 + +- **不重启**:服务返回 `NoRestartErr(err)` 表示不触发自动重启。 +- **致命错误**:使用 `AsFatalErr(err, status)` 标记为致命错误,服务会被标记为失败并停止重启。 + +示例: + +```go +if err != nil { + return supervisor.NoRestartErr(err) +} +``` + +## 调试 UI / API + +Supervisor 提供调试 UI 与 API,注册入口: + +```go +import "github.com/pubgo/lava/v2/core/supervisor/debug" + +debug.Register(manager) +``` + +注册后,调试服务会挂载在调试路由下: + +- `GET /supervisor/`:调试 UI +- `GET /supervisor/api/services`:服务列表 +- `GET /supervisor/api/service/:name`:服务详情 +- `POST /supervisor/api/service/:name/start`:启动服务 +- `POST /supervisor/api/service/:name/stop`:停止服务 +- `POST /supervisor/api/service/:name/restart`:重启服务 +- `POST /supervisor/api/service/:name/reset`:重置失败状态与计数 +- `POST /supervisor/api/services/restart`:重启所有服务 + +> 实际对外路径取决于调试服务器的挂载前缀与监听配置。 + +## 注意事项 + +1. `Add` 仅注册服务,不会立刻运行;需要通过 `Run/Serve` 启动管理器,或在管理器已启动时新增服务。 +2. `WithAutoStart(false)` 注册的服务不会随 `Run/Serve` 自动启动,可通过 `StartService` 手动启动。 +3. 建议为关键服务设置合理的 `RestartPolicy` 与窗口期上限,避免频繁崩溃导致资源消耗过高。 diff --git a/go.mod b/go.mod index 7692c4d52..c90ac7072 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/pubgo/lava/v2 -go 1.24.0 +go 1.25.0 require ( - github.com/gofiber/fiber/v2 v2.52.9 + github.com/gofiber/fiber/v3 v3.1.0 github.com/golang/protobuf v1.5.4 // indirect github.com/grandcat/zeroconf v1.0.0 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 @@ -14,17 +14,17 @@ require ( github.com/pkg/errors v0.9.1 github.com/reugn/go-quartz v0.15.1 github.com/vmihailenco/msgpack/v5 v5.4.1 - go.opentelemetry.io/otel v1.37.0 - go.opentelemetry.io/otel/metric v1.37.0 - go.opentelemetry.io/otel/trace v1.37.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/metric v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 go.uber.org/atomic v1.11.0 go.uber.org/automaxprocs v1.6.0 - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.46.0 - golang.org/x/sys v0.37.0 // indirect - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 + golang.org/x/sys v0.42.0 // indirect + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 ) require ( @@ -38,33 +38,41 @@ require ( dario.cat/mergo v1.0.2 github.com/alecthomas/participle/v2 v2.1.4 github.com/arl/statsviz v0.8.0 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/ecordell/optgen v0.0.9 github.com/expr-lang/expr v1.17.7 github.com/fasthttp/websocket v1.5.12 github.com/felixge/fgprof v0.9.5 + github.com/fsnotify/fsnotify v1.9.0 github.com/fullstorydev/grpchan v1.1.2 github.com/go-logr/logr v1.4.3 - github.com/go-playground/validator/v10 v10.27.0 - github.com/gofiber/adaptor/v2 v2.2.1 - github.com/gofiber/utils v1.1.0 + github.com/go-playground/validator/v10 v10.30.1 + github.com/gofiber/utils/v2 v2.0.2 github.com/golangci/golangci-lint v1.61.0 github.com/google/gops v0.3.28 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/go-version v1.8.0 github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19 - github.com/maragudk/gomponents v0.22.0 + github.com/libp2p/go-yamux/v5 v5.1.0 github.com/maruel/panicparse/v2 v2.5.0 - github.com/pubgo/dix/v2 v2.0.0-beta.10 - github.com/pubgo/funk/v2 v2.0.0-beta.8 - github.com/pubgo/redant v0.0.4 + github.com/miekg/dns v1.1.66 + github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 + github.com/pubgo/dix/v2 v2.0.0 + github.com/pubgo/funk/v2 v2.0.1 + github.com/pubgo/redant v0.4.0 + github.com/quic-go/quic-go v0.59.0 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/samber/lo v1.52.0 - github.com/stretchr/testify v1.10.0 - github.com/thejerf/suture/v4 v4.0.6 + github.com/shirou/gopsutil/v3 v3.24.5 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 github.com/uber-go/tally/v4 v4.1.17 github.com/ulikunitz/xz v0.5.15 - github.com/valyala/fasthttp v1.63.0 + github.com/valyala/fasthttp v1.69.0 github.com/valyala/fasttemplate v1.2.2 + github.com/xtaci/kcp-go/v5 v5.6.64 + github.com/xtaci/smux v1.5.53 go.opentelemetry.io/contrib/zpages v0.62.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 @@ -72,11 +80,12 @@ require ( go.opentelemetry.io/otel/exporters/prometheus v0.59.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 - go.opentelemetry.io/otel/sdk v1.37.0 - go.opentelemetry.io/otel/sdk/metric v1.37.0 - golang.org/x/tools v0.38.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/sdk/metric v1.39.0 + golang.org/x/tools v0.41.0 golang.org/x/vuln v1.1.3 - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 + google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 maragu.dev/gomponents v1.2.0 ) @@ -84,12 +93,13 @@ require ( require ( 4d63.com/gocheckcompilerdirectives v1.2.1 // indirect 4d63.com/gochecknoglobals v0.2.1 // indirect + cel.dev/expr v0.24.0 // indirect github.com/4meepo/tagalign v1.3.4 // indirect github.com/Abirdcfly/dupword v0.1.1 // indirect github.com/Antonboom/errname v0.1.13 // indirect github.com/Antonboom/nilnil v0.1.9 // indirect github.com/Antonboom/testifylint v1.4.3 // indirect - github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + github.com/BurntSushi/toml v1.5.0 // indirect github.com/Crocmagnon/fatcontext v0.5.2 // indirect github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0 // indirect @@ -101,6 +111,7 @@ require ( github.com/alexkohler/prealloc v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/ashanbrown/forbidigo v1.6.0 // indirect github.com/ashanbrown/makezero v1.1.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -121,23 +132,23 @@ require ( github.com/charithe/durationcheck v0.0.10 // indirect github.com/chavacava/garif v0.1.0 // indirect github.com/ckaznocha/intrange v0.2.0 // indirect - github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creasty/defaults v1.7.0 // indirect github.com/curioswitch/go-reassign v0.2.0 // indirect github.com/daixiang0/gci v0.13.5 // indirect - github.com/dave/jennifer v1.7.0 // indirect + github.com/dave/jennifer v1.7.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.5 // indirect - github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/ghostiam/protogetter v0.3.6 // indirect github.com/go-critic/go-critic v0.11.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect @@ -150,6 +161,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.1.0 // indirect github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/gofiber/schema v1.7.0 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect @@ -159,6 +171,7 @@ require ( github.com/golangci/plugin-module-register v0.1.1 // indirect github.com/golangci/revgrep v0.5.3 // indirect github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/uuid v1.6.0 // indirect @@ -167,11 +180,10 @@ require ( github.com/gostaticanalysis/comment v1.4.2 // indirect github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect github.com/gostaticanalysis/nilerr v0.1.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/huandu/go-clone v1.5.1 // indirect + github.com/huandu/go-clone v1.7.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jgautheron/goconst v1.7.1 // indirect github.com/jhump/protoreflect v1.17.0 // indirect @@ -184,7 +196,9 @@ require ( github.com/karamaru-alpha/copyloopvar v1.1.0 // indirect github.com/kisielk/errcheck v1.7.0 // indirect github.com/kkHAIKE/contextcheck v1.1.5 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/klauspost/reedsolomon v1.12.0 // indirect github.com/kulti/thelper v0.6.3 // indirect github.com/kunwardeep/paralleltest v1.0.10 // indirect github.com/kyoh86/exportloopref v0.1.11 // indirect @@ -193,25 +207,26 @@ require ( github.com/ldez/tagliatelle v0.5.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect + github.com/libp2p/go-buffer-pool v0.0.2 // indirect github.com/lmittmann/tint v1.1.2 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufeee/execinquery v1.2.1 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/macabu/inamedparam v0.1.3 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/maratori/testableexamples v1.0.0 // indirect github.com/maratori/testpackage v1.1.1 // indirect github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mgechev/revive v1.3.9 // indirect - github.com/miekg/dns v1.1.66 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/moricho/tparallel v0.3.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect @@ -221,9 +236,10 @@ require ( github.com/olekukonko/ll v0.0.9 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.6.0 // indirect - github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect @@ -243,6 +259,7 @@ require ( github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect github.com/securego/gosec/v2 v2.21.2 // indirect github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/sivchari/tenv v1.10.0 // indirect @@ -252,16 +269,20 @@ require ( github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.12.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tdakkota/asciicheck v0.2.0 // indirect github.com/tetafro/godot v1.4.17 // indirect github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 // indirect github.com/timonwong/loggercheck v0.9.4 // indirect + github.com/tinylib/msgp v1.6.3 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.9.0 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/twmb/murmur3 v1.1.8 // indirect @@ -273,20 +294,22 @@ require ( github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect go-simpler.org/musttag v0.12.2 // indirect go-simpler.org/sloglint v0.7.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.31.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect honnef.co/go/tools v0.5.1 // indirect diff --git a/go.sum b/go.sum index 6f865010d..5ef97bc49 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 4d63.com/gocheckcompilerdirectives v1.2.1/go.mod h1:yjDJSxmDTtIHHCqX0ufRYZDL6vQtMG7tJdKVeWwsqvs= 4d63.com/gochecknoglobals v0.2.1 h1:1eiorGsgHOFOuoOiJDy2psSrQbRdIHrlge0IJIkUgDc= 4d63.com/gochecknoglobals v0.2.1/go.mod h1:KRE8wtJB3CXCsb1xy421JfTHIIbmT3U5ruxw2Qu8fSU= -cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= -cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= @@ -18,8 +18,8 @@ github.com/Antonboom/nilnil v0.1.9/go.mod h1:iGe2rYwCq5/Me1khrysB4nwI7swQvjclR8/ github.com/Antonboom/testifylint v1.4.3 h1:ohMt6AHuHgttaQ1xb6SSnxCeK4/rnK7KKzbvs7DmEck= github.com/Antonboom/testifylint v1.4.3/go.mod h1:+8Q9+AOLsz5ZiQiiYujJKs9mNz398+M6UgslP4qgJLA= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Crocmagnon/fatcontext v0.5.2 h1:vhSEg8Gqng8awhPju2w7MKHqMlg4/NI+gSDHtR3xgwA= github.com/Crocmagnon/fatcontext v0.5.2/go.mod h1:87XhRMaInHP44Q7Tlc7jkgKKB7kZAOPiDkFMdKCC+74= github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= @@ -50,6 +50,8 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U= github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0= github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= @@ -65,6 +67,8 @@ github.com/bkielbasa/cyclop v1.2.1 h1:AeF71HZDob1P2/pRm1so9cd1alZnrpyc4q2uP2l0gJ github.com/bkielbasa/cyclop v1.2.1/go.mod h1:K/dT/M0FPAiYjBgQGau7tz+3TMh4FWAEqlMhzFWCrgM= github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bombsimon/wsl/v4 v4.4.1 h1:jfUaCkN+aUpobrMO24zwyAMwMAV5eSziCkOKEauOLdw= github.com/bombsimon/wsl/v4 v4.4.1/go.mod h1:Xu/kDxGZTofQcDGCtQe9KCzhHphIe0fDuyWTxER9Feo= github.com/breml/bidichk v0.2.7 h1:dAkKQPLl/Qrk7hnP6P+E0xOodrq8Us7+U0o4UBOAlQY= @@ -101,11 +105,11 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/ckaznocha/intrange v0.2.0 h1:FykcZuJ8BD7oX93YbO1UY9oZtkRbp+1/kJcDjkefYLs= github.com/ckaznocha/intrange v0.2.0/go.mod h1:r5I7nUlAAG56xmkOpw4XVr16BXhwYTUdcuRFeevn1oE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= -github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= -github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA= @@ -114,8 +118,8 @@ github.com/curioswitch/go-reassign v0.2.0 h1:G9UZyOcpk/d7Gd6mqYgd8XYWFMw/znxwGDU github.com/curioswitch/go-reassign v0.2.0/go.mod h1:x6OpXuWvgfQaMGks2BZybTngWjT84hqJfKoO8Tt/Roc= github.com/daixiang0/gci v0.13.5 h1:kThgmH1yBmZSBCh1EJVxQ7JsHpm5Oms0AMed/0LaH4c= github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= -github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= -github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= 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= @@ -127,9 +131,9 @@ github.com/ecordell/optgen v0.0.9/go.mod h1:+YZ4tk5pNGMoeH+Y4F4HeDDj0SLOlIgMMNae github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= @@ -149,14 +153,16 @@ github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6 github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fullstorydev/grpchan v1.1.2 h1:Bmo6KbPe/xvftY/8tCbV3MmX/5Z87zcXu+5Xus7EDz4= github.com/fullstorydev/grpchan v1.1.2/go.mod h1:GrXuhvxw+EM9Z1c7pHQY7SOWn8cv1+SamuCsdNH0j9A= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghostiam/protogetter v0.3.6 h1:R7qEWaSgFCsy20yYHNIJsU9ZOb8TziSRRxuAOTVKeOk= github.com/ghostiam/protogetter v0.3.6/go.mod h1:7lpeDnEJ1ZjL/YtyoN99ljO4z0pd3H0d18/t2dPBxHw= github.com/go-critic/go-critic v0.11.4 h1:O7kGOCx0NDIni4czrkRIXTnit0mkyKOCePh3My6OyEU= @@ -168,14 +174,17 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -212,12 +221,12 @@ github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/K github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4= -github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc= -github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= -github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= -github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= +github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= +github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= +github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= +github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -228,6 +237,12 @@ github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+Licev github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a h1:w8hkcTqaFpzKqonE9uMCefW1WDie15eSP/4MssdenaM= @@ -246,9 +261,14 @@ github.com/golangci/revgrep v0.5.3 h1:3tL7c1XBMtWHHqVpS5ChmiAAoe4PF/d5+ULzV9sLAz github.com/golangci/revgrep v0.5.3/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNFP0hTEi1YKjB/ub8zkpaOqFFMApi2EAs= github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed/go.mod h1:XLXN8bNw4CGRPaqgl3bv/lhz7bsGPh4/xSaMTbo2vkQ= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -286,19 +306,19 @@ github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6 github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= -github.com/huandu/go-clone v1.5.1 h1:1wlwYRlHZo4HspdOM0YQ6O7Y7bjtxTrrt+4jnDeejVo= -github.com/huandu/go-clone v1.5.1/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -333,8 +353,12 @@ github.com/kisielk/errcheck v1.7.0/go.mod h1:1kLL+jV4e+CFfueBmI1dSK2ADDyQnlrnrY/ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkHAIKE/contextcheck v1.1.5 h1:CdnJh63tcDe53vG+RebdpdXJTc9atMgGqdx8LXxiilg= github.com/kkHAIKE/contextcheck v1.1.5/go.mod h1:O930cpht4xb1YQpK+1+AgoM3mFsvxr7uyFptcnWTYUA= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno= +github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -362,21 +386,25 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= +github.com/libp2p/go-buffer-pool v0.0.2 h1:QNK2iAFa8gjAe1SPz6mHSMuCcjs+X1wlHzeOSqcmlfs= +github.com/libp2p/go-buffer-pool v0.0.2/go.mod h1:MvaB6xw5vOrDl8rYZGLFdKAuk/hRoRZd1Vi32+RXyFM= +github.com/libp2p/go-yamux/v5 v5.1.0 h1:8Qlxj4E9JGJAQVW6+uj2o7mqkqsIVlSUGmTWhlXzoHE= +github.com/libp2p/go-yamux/v5 v5.1.0/go.mod h1:tgIQ07ObtRR/I0IWsFOyQIL9/dR5UXgc2s8xKmNZv1o= github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufeee/execinquery v1.2.1 h1:hf0Ems4SHcUGBxpGN7Jz78z1ppVkP/837ZlETPCEtOM= github.com/lufeee/execinquery v1.2.1/go.mod h1:EC7DrEKView09ocscGHC+apXMIaorh4xqSxS/dy8SbM= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailgun/holster/v4 v4.21.0 h1:EH3fwKEGv56WA5gUwxjOTqZbeILY+oJ/VWEo1xku7t8= github.com/mailgun/holster/v4 v4.21.0/go.mod h1:G06Q741dj+zsH1WFrmoFvih3LtaocvBIoNtxITdWEtg= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/maragudk/gomponents v0.22.0 h1:0gNrSDC1nM6w0Vxj5wgGXqV8frDH9UVPE+dEyy4ApPQ= -github.com/maragudk/gomponents v0.22.0/go.mod h1:nHkNnZL6ODgMBeJhrZjkMHVvNdoYsfmpKB2/hjdQ0Hg= github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= @@ -394,8 +422,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mgechev/revive v1.3.9 h1:18Y3R4a2USSBF+QZKFQwVkBROUda7uoBlkEuBD+YD1A= github.com/mgechev/revive v1.3.9/go.mod h1:+uxEIr5UH0TjXWHTno3xh4u7eg6jDpXKzQccA9UGhHU= github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= @@ -414,8 +442,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= @@ -449,6 +477,8 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -459,6 +489,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.6.0 h1:tftWV9DE7txiFzPpztTAwyoRLKNj9gpVm2cg8/OwcYY= github.com/polyfloyd/go-errorlint v1.6.0/go.mod h1:HR7u8wuP1kb1NeN1zqTd1ZMlqUKPPHF+Id4vIPvDqVw= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 h1:eR+0HE//Ciyfwy3HC7fjRyKShSJHYoX2Pv7pPshjK/Q= @@ -472,12 +504,12 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2 github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/pubgo/dix/v2 v2.0.0-beta.10 h1:HE1gqY8vzNPPdz4FwN91hWVZpeWkfvuIRAT7dGSgdIw= -github.com/pubgo/dix/v2 v2.0.0-beta.10/go.mod h1:jV/9KWf+YxtoQATuZLyUraACduxHvfaum5EZDSCK5gE= -github.com/pubgo/funk/v2 v2.0.0-beta.8 h1:zYL/4Cp4T1QuQnEp1EI4jGMsRKaHB91s7sdANSrkebw= -github.com/pubgo/funk/v2 v2.0.0-beta.8/go.mod h1:uMQn+vuKx++99J+QZnYTekqzwJHT/j7lAz/qwqQ8PyY= -github.com/pubgo/redant v0.0.4 h1:Yweyxj33Y+j4eE9b36QAn9FcOWPymUE0CxaqOrJgTvs= -github.com/pubgo/redant v0.0.4/go.mod h1:FOBNjL8pPLOBcZS3SL2R5GusFz/bNBwDJzSinGuKs7A= +github.com/pubgo/dix/v2 v2.0.0 h1:cNotBsdQ4epD+XGfaMdaN/ZRNafrqQJEVuitj0gTg/0= +github.com/pubgo/dix/v2 v2.0.0/go.mod h1:rAJWKUI4cgRAr8Mb3+H0kjsl3VgR54RU/2Fzbe60Ag0= +github.com/pubgo/funk/v2 v2.0.1 h1:X/7DUwSh7TKcKL3RZfFgcx88ZtLvE14RHvEPwWLU96g= +github.com/pubgo/funk/v2 v2.0.1/go.mod h1:SZMBocKxdV1EY0iKbK+mdw4jUzIp/i3SDWMPFNU8pv0= +github.com/pubgo/redant v0.4.0 h1:HLKANgeXG7SOhiLxUvZNVNGjNPCJPWvZQtYyKZEgSqw= +github.com/pubgo/redant v0.4.0/go.mod h1:pJXH/Im4+1yrUO7AmmQ5lspYzjfKWyW4bCiLg6B41pk= github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 h1:+Wl/0aFp0hpuHM3H//KMft64WQ1yX9LdJY64Qm/gFCo= github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1/go.mod h1:GJLgqsLeo4qgavUoL8JeGFNS7qcisx3awV/w9eWTmNI= github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= @@ -488,9 +520,10 @@ github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/reugn/go-quartz v0.15.1 h1:8hMtC+ERa9G5tiL9fiuBpPreId+Tu1Xt2pkXVeTZAy0= github.com/reugn/go-quartz v0.15.1/go.mod h1:00DVnBKq2Fxag/HlR9mGXjmHNlMFQ1n/LNM+Fn0jUaE= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -520,8 +553,16 @@ github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa2 github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/securego/gosec/v2 v2.21.2 h1:deZp5zmYf3TWwU7A7cR2+SolbTpZ3HQiwFqnzQyEl3M= github.com/securego/gosec/v2 v2.21.2/go.mod h1:au33kg78rNseF5PwPnTWhuYBFf534bvJRvOrgZ/bFzU= +github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= +github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -552,6 +593,8 @@ github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YE github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.1.1 h1:tYugd/yrm1O0dV+ThCbaKZh195Dfm07ysF0U6JQXczc= github.com/stbenjam/no-sprintf-host-port v0.1.1/go.mod h1:TLhvtIvONRzdmkFiio4O8LHsN9N74I+PhRquPsxpL0I= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -566,8 +609,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -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/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tdakkota/asciicheck v0.2.0 h1:o8jvnUANo0qXtnslk2d3nMKTFNlOnJjRrNcj0j9qkHM= @@ -578,18 +621,24 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.4.17 h1:pGzu+Ye7ZUEFx7LHU0dAKmCOXWsPjl7qA6iMGndsjPs= github.com/tetafro/godot v1.4.17/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= -github.com/thejerf/suture/v4 v4.0.6 h1:QsuCEsCqb03xF9tPAsWAj8QOAJBgQI1c0VqJNaingg8= -github.com/thejerf/suture/v4 v4.0.6/go.mod h1:gu9Y4dXNUWFrByqRt30Rm9/UZ0wzRSt9AJS6xu/ZGxU= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 h1:quvGphlmUVU+nhpFa4gg4yJyTRJ13reZMDHrKwYw53M= github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966/go.mod h1:27bSVNWSBOHm+qRp1T9qzaIpsWEP6TbUnei/43HK+PQ= github.com/timonwong/loggercheck v0.9.4 h1:HKKhqrjcVj8sxL7K77beXh0adEm6DLjV/QOGeMXEVi4= github.com/timonwong/loggercheck v0.9.4/go.mod h1:caz4zlPcgvpEkXgVnAJGowHAMW2NwHaNlpS8xDbVhTg= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tomarrell/wrapcheck/v2 v2.9.0 h1:801U2YCAjLhdN8zhZ/7tdjB3EnAoRlJHt/s+9hijLQ4= github.com/tomarrell/wrapcheck/v2 v2.9.0/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= @@ -608,16 +657,24 @@ github.com/uudashr/gocognit v1.1.3 h1:l+a111VcDbKfynh+airAy/DJQKaXh2m9vkoysMPSZy github.com/uudashr/gocognit v1.1.3/go.mod h1:aKH8/e8xbTRBwjbCkwZ8qt4l2EpKXl31KMHgSS+lZ2U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.63.0 h1:DisIL8OjB7ul2d7cBaMRcKTQDYnrGy56R4FCiuDP0Ns= -github.com/valyala/fasthttp v1.63.0/go.mod h1:REc4IeW+cAEyLrRPa5A81MIjvz0QE1laoTX2EaPHKJM= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU= github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg= +github.com/xtaci/kcp-go/v5 v5.6.64 h1:IerWqYNk2pyen8FBsLoeY4buQGXPRFmdxR1838FMt/Y= +github.com/xtaci/kcp-go/v5 v5.6.64/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE= +github.com/xtaci/smux v1.5.53 h1:M4ultpvpEtbJ4kq6RXHwVTW+vZsY66Xca4TOlryIXy0= +github.com/xtaci/smux v1.5.53/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= @@ -633,6 +690,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= @@ -641,12 +700,12 @@ go-simpler.org/musttag v0.12.2 h1:J7lRc2ysXOq7eM8rwaTYnNrHd5JwjppzB6mScysB2Cs= go-simpler.org/musttag v0.12.2/go.mod h1:uN1DVIasMTQKk6XSik7yrJoEysGtR2GRqvWnI9S7TYM= go-simpler.org/sloglint v0.7.2 h1:Wc9Em/Zeuu7JYpl+oKoYOsQSy2X560aVueCW/m6IijY= go-simpler.org/sloglint v0.7.2/go.mod h1:US+9C80ppl7VsThQclkM7BkCHQAzuz8kHLsW3ppuluo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/zpages v0.62.0 h1:9fUYTLmrK0x/lweM2uM+BOx069jLx8PxVqWhegGJ9Bo= go.opentelemetry.io/contrib/zpages v0.62.0/go.mod h1:C8kXoiC1Ytvereztus2R+kqdSa6W/MZ8FfS8Zwj+LiM= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= @@ -659,14 +718,14 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iL go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -677,22 +736,25 @@ go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwE go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= @@ -712,8 +774,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -724,6 +786,7 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= @@ -733,8 +796,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -745,16 +808,18 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -769,25 +834,24 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= -golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -796,8 +860,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -827,10 +893,10 @@ golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= -golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/vuln v1.1.3 h1:NPGnvPOTgnjBc9HTaUx+nj+EaUYxl5SJOWqaDYGaFYw= @@ -839,30 +905,41 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/internal/configs/components/logger.yaml b/internal/configs/components/logger.yaml index 60acd39ab..d11d7eab4 100644 --- a/internal/configs/components/logger.yaml +++ b/internal/configs/components/logger.yaml @@ -1,5 +1,8 @@ logger: level: ${LOG_LEVEL:-"debug"} as_json: ${LOG_AS_JSON:-false} - filters: - - level not in ["debug"] + # filters: + # - level not in ["debug"] + file: + path: ".local/log/app.log" + enabled: true diff --git a/internal/configs/components/tunnel.yaml b/internal/configs/components/tunnel.yaml new file mode 100644 index 000000000..1e0211555 --- /dev/null +++ b/internal/configs/components/tunnel.yaml @@ -0,0 +1,25 @@ +# Tunnel Gateway 配置 +tunnel: + # Agent 连接监听地址 + # Agent 主动连接此地址注册服务 + listen_addr: ":7007" + + # HTTP 代理端口 + # 外部 HTTP 请求通过此端口访问已注册的服务 + # URL 格式: http://gateway:8888/{service_name}/path + http_port: 8888 + + # gRPC 代理端口 + # 外部 gRPC 请求通过此端口访问已注册的服务 + grpc_port: 9999 + + # Debug 代理端口 + # 外部可通过此端口访问已注册服务的 debug 接口 + # URL 格式: http://gateway:6066/{service_name}/debug/pprof + debug_port: 6066 + +# Tunnel Agent 配置 (用于服务端连接到 Gateway) +tunnel_agent: + enabled: true + gateway_addr: "localhost:7007" + service_addr: ":8082" diff --git a/internal/configs/envs/envs.yaml b/internal/configs/envs/envs.yaml new file mode 100644 index 000000000..1a1b3cedf --- /dev/null +++ b/internal/configs/envs/envs.yaml @@ -0,0 +1,50 @@ +OPENAI_API_KEY: + desc: "OpenAI API Key" + validate: "required" +OPENAI_BASE_URL: + desc: "OpenAI Base URL" + default: "https://api.deepseek.com/v1" +OPENAI_MODEL: + desc: "OpenAI Model" + default: "deepseek-chat" +ENABLE_DEBUG: + desc: "enable debug" + default: "false" +DB_DSN: + desc: "db dsn" + default: "host=ec2-3-114-17-139.ap-northeast-1.compute.amazonaws.com dbname=welogin port=15432 user=postgres password=QMDifpRFo3zbN01ySrJg sslmode=disable" +ENV: + desc: app env + default: dev +CATDOGS_BASIC_AUTH_PASSWD: + desc: basic auth password + default: "123456" +GOOGLE_CALLBACK_URL: + desc: "google callback url" + default: "https://travel.chameleontravel.club/chameleon/auth/authorize/google/callback" +LOG_LEVEL: + desc: "log level" + default: "debug" + +LOG_AS_JSON: + desc: "log as json" + default: "false" +NATS_URL: + desc: nats url +NATS_POOL_WORKER_SIZE: + desc: nats pool worker size + default: "1000" +NATS_POOL_QUEUE_SIZE: + desc: nats pool queue size + default: 1000 +NATS_ENABLE_IGNORE: + desc: nats enable ignore + default: true + +REDIS_ADDR: + desc: redis address + default: "localhost:6379" + +REDIS_PWD: + desc: redis password + default: "123456" diff --git a/internal/configs/envs/s3.yaml b/internal/configs/envs/s3.yaml new file mode 100644 index 000000000..f49468adf --- /dev/null +++ b/internal/configs/envs/s3.yaml @@ -0,0 +1,17 @@ +S3_AK: + desc: "S3 access key" + value: "" + +S3_SK: + desc: "S3 secret key" + +S3_BUCKET: + desc: "S3 bucket name" + +S3_REGION: + desc: "S3 region" + value: "us-west-1" + +S3_FORCE_BUCKET: + desc: "S3 force bucket" + value: false diff --git a/internal/configs/envs/website.yaml b/internal/configs/envs/website.yaml new file mode 100644 index 000000000..24a3f9a94 --- /dev/null +++ b/internal/configs/envs/website.yaml @@ -0,0 +1,11 @@ +WEB_BASE_URL: + desc: "website base url" + +WEB_CHANGE_PWD_PATH: + desc: "website change password uri path" + +WEB_USER_ACTIVATE_PATH: + desc: "website user activate uri path" + +BACKEND_URL: + desc: "backend base url" diff --git a/internal/configs/scheduler.yaml b/internal/configs/scheduler.yaml index e62322fb6..4b2eb4643 100644 --- a/internal/configs/scheduler.yaml +++ b/internal/configs/scheduler.yaml @@ -5,4 +5,5 @@ patch_resources: - .local.yaml patch_envs: - - envs/envs.yaml + - envs + diff --git a/internal/configs/tunnel.yaml b/internal/configs/tunnel.yaml new file mode 100644 index 000000000..ea6ce7d4b --- /dev/null +++ b/internal/configs/tunnel.yaml @@ -0,0 +1,8 @@ +resources: + - components + +patch_resources: + - .local.yaml + +patch_envs: + - envs diff --git a/internal/examples/fileserver/main.go b/internal/examples/fileserver/main.go index 73b66447e..1406a06a5 100644 --- a/internal/examples/fileserver/main.go +++ b/internal/examples/fileserver/main.go @@ -6,11 +6,11 @@ import ( "github.com/pubgo/funk/v2/env" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/lava/v2/cmds/fileservercmd" "github.com/pubgo/lava/v2/core/lavabuilder" "github.com/pubgo/lava/v2/core/logging/logext/slog" + "github.com/pubgo/lava/v2/core/running" ) func main() { diff --git a/internal/examples/grpcweb/frontend/dist/assets/index-4ouSPqqH.js b/internal/examples/grpcweb/frontend/dist/assets/index-4ouSPqqH.js new file mode 100644 index 000000000..0d3e2da37 --- /dev/null +++ b/internal/examples/grpcweb/frontend/dist/assets/index-4ouSPqqH.js @@ -0,0 +1,5 @@ +var je=Object.defineProperty;var Ce=(r,e,t)=>e in r?je(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t;var W=(r,e,t)=>Ce(r,typeof e!="symbol"?e+"":e,t);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function t(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=t(s);fetch(s.href,i)}})();function Ae(r){let e=typeof r;if(e=="object"){if(Array.isArray(r))return"array";if(r===null)return"null"}return e}function Ke(r){return r!==null&&typeof r=="object"&&!Array.isArray(r)}let $="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split(""),re=[];for(let r=0;r<$.length;r++)re[$[r].charCodeAt(0)]=r;re[45]=$.indexOf("+");re[95]=$.indexOf("/");function De(r){let e=r.length*3/4;r[r.length-2]=="="?e-=2:r[r.length-1]=="="&&(e-=1);let t=new Uint8Array(e),n=0,s=0,i,o=0;for(let a=0;a>4,o=i,s=2;break;case 2:t[n++]=(o&15)<<4|(i&60)>>2,o=i,s=3;break;case 3:t[n++]=(o&3)<<6|i,s=0;break}}if(s==1)throw Error("invalid base64 string.");return t.subarray(0,n)}function Re(r){let e="",t=0,n,s=0;for(let i=0;i>2],s=(n&3)<<4,t=1;break;case 1:e+=$[s|n>>4],s=(n&15)<<2,t=2;break;case 2:e+=$[s|n>>6],e+=$[n&63],t=0;break}return t&&(e+=$[s],e+="=",t==1&&(e+="=")),e}var F;(function(r){r.symbol=Symbol.for("protobuf-ts/unknown"),r.onRead=(t,n,s,i,o)=>{(e(n)?n[r.symbol]:n[r.symbol]=[]).push({no:s,wireType:i,data:o})},r.onWrite=(t,n,s)=>{for(let{no:i,wireType:o,data:a}of r.list(n))s.tag(i,o).raw(a)},r.list=(t,n)=>{if(e(t)){let s=t[r.symbol];return n?s.filter(i=>i.no==n):s}return[]},r.last=(t,n)=>r.list(t,n).slice(-1)[0];const e=t=>t&&Array.isArray(t[r.symbol])})(F||(F={}));function Xe(r,e){return Object.assign(Object.assign({},r),e)}var y;(function(r){r[r.Varint=0]="Varint",r[r.Bit64=1]="Bit64",r[r.LengthDelimited=2]="LengthDelimited",r[r.StartGroup=3]="StartGroup",r[r.EndGroup=4]="EndGroup",r[r.Bit32=5]="Bit32"})(y||(y={}));function Je(){let r=0,e=0;for(let n=0;n<28;n+=7){let s=this.buf[this.pos++];if(r|=(s&127)<>4,(t&128)==0)return this.assertBounds(),[r,e];for(let n=3;n<=31;n+=7){let s=this.buf[this.pos++];if(e|=(s&127)<>>i,a=!(!(o>>>7)&&e==0),l=(a?o|128:o)&255;if(t.push(l),!a)return}const n=r>>>28&15|(e&7)<<4,s=e>>3!=0;if(t.push((s?n|128:n)&255),!!s){for(let i=3;i<31;i=i+7){const o=e>>>i,a=!!(o>>>7),l=(a?o|128:o)&255;if(t.push(l),!a)return}t.push(e>>>31&1)}}const Y=65536*65536;function Be(r){let e=r[0]=="-";e&&(r=r.slice(1));const t=1e6;let n=0,s=0;function i(o,a){const l=Number(r.slice(o,a));s*=t,n=n*t+l,n>=Y&&(s=s+(n/Y|0),n=n%Y)}return i(-24,-18),i(-18,-12),i(-12,-6),i(-6),[e,n,s]}function le(r,e){if(e>>>0<=2097151)return""+(Y*e+(r>>>0));let t=r&16777215,n=(r>>>24|e<<8)>>>0&16777215,s=e>>16&65535,i=t+n*6777216+s*6710656,o=n+s*8147497,a=s*2,l=1e7;i>=l&&(o+=Math.floor(i/l),i%=l),o>=l&&(a+=Math.floor(o/l),o%=l);function c(h,m){let g=h?String(h):"";return m?"0000000".slice(g.length)+g:g}return c(a,0)+c(o,a)+c(i,1)}function me(r,e){if(r>=0){for(;r>127;)e.push(r&127|128),r=r>>>7;e.push(r)}else{for(let t=0;t<9;t++)e.push(r&127|128),r=r>>7;e.push(1)}}function We(){let r=this.buf[this.pos++],e=r&127;if((r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<7,(r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<14,(r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<21,(r&128)==0)return this.assertBounds(),e;r=this.buf[this.pos++],e|=(r&15)<<28;for(let t=5;(r&128)!==0&&t<10;t++)r=this.buf[this.pos++];if((r&128)!=0)throw new Error("invalid varint");return this.assertBounds(),e>>>0}let w;function Ge(){const r=new DataView(new ArrayBuffer(8));w=globalThis.BigInt!==void 0&&typeof r.getBigInt64=="function"&&typeof r.getBigUint64=="function"&&typeof r.setBigInt64=="function"&&typeof r.setBigUint64=="function"?{MIN:BigInt("-9223372036854775808"),MAX:BigInt("9223372036854775807"),UMIN:BigInt("0"),UMAX:BigInt("18446744073709551615"),C:BigInt,V:r}:void 0}Ge();function Le(r){if(!r)throw new Error("BigInt unavailable, see https://github.com/timostamm/protobuf-ts/blob/v1.0.8/MANUAL.md#bigint-support")}const Fe=/^-?[0-9]+$/,z=4294967296,G=2147483648;class _e{constructor(e,t){this.lo=e|0,this.hi=t|0}isZero(){return this.lo==0&&this.hi==0}toNumber(){let e=this.hi*z+(this.lo>>>0);if(!Number.isSafeInteger(e))throw new Error("cannot convert to safe number");return e}}class D extends _e{static from(e){if(w)switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=="")throw new Error("string is no integer");e=w.C(e);case"number":if(e===0)return this.ZERO;e=w.C(e);case"bigint":if(!e)return this.ZERO;if(ew.UMAX)throw new Error("ulong too large");return w.V.setBigUint64(0,e,!0),new D(w.V.getInt32(0,!0),w.V.getInt32(4,!0))}else switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=e.trim(),!Fe.test(e))throw new Error("string is no integer");let[t,n,s]=Be(e);if(t)throw new Error("signed value for ulong");return new D(n,s);case"number":if(e==0)return this.ZERO;if(!Number.isSafeInteger(e))throw new Error("number is no integer");if(e<0)throw new Error("signed value for ulong");return new D(e,e/z)}throw new Error("unknown value "+typeof e)}toString(){return w?this.toBigInt().toString():le(this.lo,this.hi)}toBigInt(){return Le(w),w.V.setInt32(0,this.lo,!0),w.V.setInt32(4,this.hi,!0),w.V.getBigUint64(0,!0)}}D.ZERO=new D(0,0);class E extends _e{static from(e){if(w)switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=="")throw new Error("string is no integer");e=w.C(e);case"number":if(e===0)return this.ZERO;e=w.C(e);case"bigint":if(!e)return this.ZERO;if(ew.MAX)throw new Error("signed long too large");return w.V.setBigInt64(0,e,!0),new E(w.V.getInt32(0,!0),w.V.getInt32(4,!0))}else switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=e.trim(),!Fe.test(e))throw new Error("string is no integer");let[t,n,s]=Be(e);if(t){if(s>G||s==G&&n!=0)throw new Error("signed long too small")}else if(s>=G)throw new Error("signed long too large");let i=new E(n,s);return t?i.negate():i;case"number":if(e==0)return this.ZERO;if(!Number.isSafeInteger(e))throw new Error("number is no integer");return e>0?new E(e,e/z):new E(-e,-e/z).negate()}throw new Error("unknown value "+typeof e)}isNegative(){return(this.hi&G)!==0}negate(){let e=~this.hi,t=this.lo;return t?t=~t+1:e+=1,new E(t,e)}toString(){if(w)return this.toBigInt().toString();if(this.isNegative()){let e=this.negate();return"-"+le(e.lo,e.hi)}return le(this.lo,this.hi)}toBigInt(){return Le(w),w.V.setInt32(0,this.lo,!0),w.V.setInt32(4,this.hi,!0),w.V.getBigInt64(0,!0)}}E.ZERO=new E(0,0);const de={readUnknownField:!0,readerFactory:r=>new He(r)};function qe(r){return r?Object.assign(Object.assign({},de),r):de}class He{constructor(e,t){this.varint64=Je,this.uint32=We,this.buf=e,this.len=e.length,this.pos=0,this.view=new DataView(e.buffer,e.byteOffset,e.byteLength),this.textDecoder=t??new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0})}tag(){let e=this.uint32(),t=e>>>3,n=e&7;if(t<=0||n<0||n>5)throw new Error("illegal tag: field no "+t+" wire type "+n);return[t,n]}skip(e){let t=this.pos;switch(e){case y.Varint:for(;this.buf[this.pos++]&128;);break;case y.Bit64:this.pos+=4;case y.Bit32:this.pos+=4;break;case y.LengthDelimited:let n=this.uint32();this.pos+=n;break;case y.StartGroup:let s;for(;(s=this.tag()[1])!==y.EndGroup;)this.skip(s);break;default:throw new Error("cant skip wire type "+e)}return this.assertBounds(),this.buf.subarray(t,this.pos)}assertBounds(){if(this.pos>this.len)throw new RangeError("premature EOF")}int32(){return this.uint32()|0}sint32(){let e=this.uint32();return e>>>1^-(e&1)}int64(){return new E(...this.varint64())}uint64(){return new D(...this.varint64())}sint64(){let[e,t]=this.varint64(),n=-(e&1);return e=(e>>>1|(t&1)<<31)^n,t=t>>>1^n,new E(e,t)}bool(){let[e,t]=this.varint64();return e!==0||t!==0}fixed32(){return this.view.getUint32((this.pos+=4)-4,!0)}sfixed32(){return this.view.getInt32((this.pos+=4)-4,!0)}fixed64(){return new D(this.sfixed32(),this.sfixed32())}sfixed64(){return new E(this.sfixed32(),this.sfixed32())}float(){return this.view.getFloat32((this.pos+=4)-4,!0)}double(){return this.view.getFloat64((this.pos+=8)-8,!0)}bytes(){let e=this.uint32(),t=this.pos;return this.pos+=e,this.assertBounds(),this.buf.subarray(t,t+e)}string(){return this.textDecoder.decode(this.bytes())}}function p(r,e){if(!r)throw new Error(e)}const Ze=34028234663852886e22,Ye=-34028234663852886e22,ze=4294967295,Qe=2147483647,et=-2147483648;function J(r){if(typeof r!="number")throw new Error("invalid int 32: "+typeof r);if(!Number.isInteger(r)||r>Qe||rze||r<0)throw new Error("invalid uint 32: "+r)}function he(r){if(typeof r!="number")throw new Error("invalid float 32: "+typeof r);if(Number.isFinite(r)&&(r>Ze||rnew nt};function tt(r){return r?Object.assign(Object.assign({},pe),r):pe}class nt{constructor(e){this.stack=[],this.textEncoder=e??new TextEncoder,this.chunks=[],this.buf=[]}finish(){this.chunks.push(new Uint8Array(this.buf));let e=0;for(let s=0;s>>0)}raw(e){return this.buf.length&&(this.chunks.push(new Uint8Array(this.buf)),this.buf=[]),this.chunks.push(e),this}uint32(e){for(Q(e);e>127;)this.buf.push(e&127|128),e=e>>>7;return this.buf.push(e),this}int32(e){return J(e),me(e,this.buf),this}bool(e){return this.buf.push(e?1:0),this}bytes(e){return this.uint32(e.byteLength),this.raw(e)}string(e){let t=this.textEncoder.encode(e);return this.uint32(t.byteLength),this.raw(t)}float(e){he(e);let t=new Uint8Array(4);return new DataView(t.buffer).setFloat32(0,e,!0),this.raw(t)}double(e){let t=new Uint8Array(8);return new DataView(t.buffer).setFloat64(0,e,!0),this.raw(t)}fixed32(e){Q(e);let t=new Uint8Array(4);return new DataView(t.buffer).setUint32(0,e,!0),this.raw(t)}sfixed32(e){J(e);let t=new Uint8Array(4);return new DataView(t.buffer).setInt32(0,e,!0),this.raw(t)}sint32(e){return J(e),e=(e<<1^e>>31)>>>0,me(e,this.buf),this}sfixed64(e){let t=new Uint8Array(8),n=new DataView(t.buffer),s=E.from(e);return n.setInt32(0,s.lo,!0),n.setInt32(4,s.hi,!0),this.raw(t)}fixed64(e){let t=new Uint8Array(8),n=new DataView(t.buffer),s=D.from(e);return n.setInt32(0,s.lo,!0),n.setInt32(4,s.hi,!0),this.raw(t)}int64(e){let t=E.from(e);return ie(t.lo,t.hi,this.buf),this}sint64(e){let t=E.from(e),n=t.hi>>31,s=t.lo<<1^n,i=(t.hi<<1|t.lo>>>31)^n;return ie(s,i,this.buf),this}uint64(e){let t=D.from(e);return ie(t.lo,t.hi,this.buf),this}}const ge={emitDefaultValues:!1,enumAsInteger:!1,useProtoFieldName:!1,prettySpaces:0},be={ignoreUnknownFields:!1};function rt(r){return r?Object.assign(Object.assign({},be),r):be}function st(r){return r?Object.assign(Object.assign({},ge),r):ge}function it(r,e){var t,n;let s=Object.assign(Object.assign({},r),e);return s.typeRegistry=[...(t=r==null?void 0:r.typeRegistry)!==null&&t!==void 0?t:[],...(n=e==null?void 0:e.typeRegistry)!==null&&n!==void 0?n:[]],s}const Ue=Symbol.for("protobuf-ts/message-type");function ce(r){let e=!1;const t=[];for(let n=0;n!s.includes(o))||!n&&s.some(o=>!i.known.includes(o)))return!1;if(t<1)return!0;for(const o of i.oneofs){const a=e[o];if(!at(a))return!1;if(a.oneofKind===void 0)continue;const l=this.fields.find(c=>c.localName===a.oneofKind);if(!l||!this.field(a[a.oneofKind],l,n,t))return!1}for(const o of this.fields)if(o.oneof===void 0&&!this.field(e[o.localName],o,n,t))return!1;return!0}field(e,t,n,s){let i=t.repeat;switch(t.kind){case"scalar":return e===void 0?t.opt:i?this.scalars(e,t.T,s,t.L):this.scalar(e,t.T,t.L);case"enum":return e===void 0?t.opt:i?this.scalars(e,f.INT32,s):this.scalar(e,f.INT32);case"message":return e===void 0?!0:i?this.messages(e,t.T(),n,s):this.message(e,t.T(),n,s);case"map":if(typeof e!="object"||e===null)return!1;if(s<2)return!0;if(!this.mapKeys(e,t.K,s))return!1;switch(t.V.kind){case"scalar":return this.scalars(Object.values(e),t.V.T,s,t.V.L);case"enum":return this.scalars(Object.values(e),f.INT32,s);case"message":return this.messages(Object.values(e),t.V.T(),n,s)}break}return!0}message(e,t,n,s){return n?t.isAssignable(e,s):t.is(e,s)}messages(e,t,n,s){if(!Array.isArray(e))return!1;if(s<2)return!0;if(n){for(let i=0;iparseInt(i)),t,n);case f.BOOL:return this.scalars(s.slice(0,n).map(i=>i=="true"?!0:i=="false"?!1:i),t,n);default:return this.scalars(s,t,n,V.STRING)}}}function _(r,e){switch(e){case V.BIGINT:return r.toBigInt();case V.NUMBER:return r.toNumber();default:return r.toString()}}class ct{constructor(e){this.info=e}prepare(){var e;if(this.fMap===void 0){this.fMap={};const t=(e=this.info.fields)!==null&&e!==void 0?e:[];for(const n of t)this.fMap[n.name]=n,this.fMap[n.jsonName]=n,this.fMap[n.localName]=n}}assert(e,t,n){if(!e){let s=Ae(n);throw(s=="number"||s=="boolean")&&(s=n.toString()),new Error(`Cannot parse JSON ${s} for ${this.info.typeName}#${t}`)}}read(e,t,n){this.prepare();const s=[];for(const[i,o]of Object.entries(e)){const a=this.fMap[i];if(!a){if(!n.ignoreUnknownFields)throw new Error(`Found unknown field while reading ${this.info.typeName} from JSON format. JSON key: ${i}`);continue}const l=a.localName;let c;if(a.oneof){if(o===null&&(a.kind!=="enum"||a.T()[0]!=="google.protobuf.NullValue"))continue;if(s.includes(a.oneof))throw new Error(`Multiple members of the oneof group "${a.oneof}" of ${this.info.typeName} are present in JSON.`);s.push(a.oneof),c=t[a.oneof]={oneofKind:l}}else c=t;if(a.kind=="map"){if(o===null)continue;this.assert(Ke(o),a.name,o);const h=c[l];for(const[m,g]of Object.entries(o)){this.assert(g!==null,a.name+" map value",null);let O;switch(a.V.kind){case"message":O=a.V.T().internalJsonRead(g,n);break;case"enum":if(O=this.enum(a.V.T(),g,a.name,n.ignoreUnknownFields),O===!1)continue;break;case"scalar":O=this.scalar(g,a.V.T,a.V.L,a.name);break}this.assert(O!==void 0,a.name+" map value",g);let R=m;a.K==f.BOOL&&(R=R=="true"?!0:R=="false"?!1:R),R=this.scalar(R,a.K,V.STRING,a.name).toString(),h[R]=O}}else if(a.repeat){if(o===null)continue;this.assert(Array.isArray(o),a.name,o);const h=c[l];for(const m of o){this.assert(m!==null,a.name,null);let g;switch(a.kind){case"message":g=a.T().internalJsonRead(m,n);break;case"enum":if(g=this.enum(a.T(),m,a.name,n.ignoreUnknownFields),g===!1)continue;break;case"scalar":g=this.scalar(m,a.T,a.L,a.name);break}this.assert(g!==void 0,a.name,o),h.push(g)}}else switch(a.kind){case"message":if(o===null&&a.T().typeName!="google.protobuf.Value"){this.assert(a.oneof===void 0,a.name+" (oneof member)",null);continue}c[l]=a.T().internalJsonRead(o,n,c[l]);break;case"enum":if(o===null)continue;let h=this.enum(a.T(),o,a.name,n.ignoreUnknownFields);if(h===!1)continue;c[l]=h;break;case"scalar":if(o===null)continue;c[l]=this.scalar(o,a.T,a.L,a.name);break}}}enum(e,t,n,s){if(e[0]=="google.protobuf.NullValue"&&p(t===null||t==="NULL_VALUE",`Unable to parse field ${this.info.typeName}#${n}, enum ${e[0]} only accepts null.`),t===null)return 0;switch(typeof t){case"number":return p(Number.isInteger(t),`Unable to parse field ${this.info.typeName}#${n}, enum can only be integral number, got ${t}.`),t;case"string":let i=t;e[2]&&t.substring(0,e[2].length)===e[2]&&(i=t.substring(e[2].length));let o=e[1][i];return typeof o>"u"&&s?!1:(p(typeof o=="number",`Unable to parse field ${this.info.typeName}#${n}, enum ${e[0]} has no value for "${t}".`),o)}p(!1,`Unable to parse field ${this.info.typeName}#${n}, cannot parse enum value from ${typeof t}".`)}scalar(e,t,n,s){let i;try{switch(t){case f.DOUBLE:case f.FLOAT:if(e===null)return 0;if(e==="NaN")return Number.NaN;if(e==="Infinity")return Number.POSITIVE_INFINITY;if(e==="-Infinity")return Number.NEGATIVE_INFINITY;if(e===""){i="empty string";break}if(typeof e=="string"&&e.trim().length!==e.length){i="extra whitespace";break}if(typeof e!="string"&&typeof e!="number")break;let o=Number(e);if(Number.isNaN(o)){i="not a number";break}if(!Number.isFinite(o)){i="too large or small";break}return t==f.FLOAT&&he(o),o;case f.INT32:case f.FIXED32:case f.SFIXED32:case f.SINT32:case f.UINT32:if(e===null)return 0;let a;if(typeof e=="number"?a=e:e===""?i="empty string":typeof e=="string"&&(e.trim().length!==e.length?i="extra whitespace":a=Number(e)),a===void 0)break;return t==f.UINT32?Q(a):J(a),a;case f.INT64:case f.SFIXED64:case f.SINT64:if(e===null)return _(E.ZERO,n);if(typeof e!="number"&&typeof e!="string")break;return _(E.from(e),n);case f.FIXED64:case f.UINT64:if(e===null)return _(D.ZERO,n);if(typeof e!="number"&&typeof e!="string")break;return _(D.from(e),n);case f.BOOL:if(e===null)return!1;if(typeof e!="boolean")break;return e;case f.STRING:if(e===null)return"";if(typeof e!="string"){i="extra whitespace";break}try{encodeURIComponent(e)}catch(l){l="invalid UTF8";break}return e;case f.BYTES:if(e===null||e==="")return new Uint8Array(0);if(typeof e!="string")break;return De(e)}}catch(o){i=o.message}this.assert(!1,s+(i?" - "+i:""),e)}}class ft{constructor(e){var t;this.fields=(t=e.fields)!==null&&t!==void 0?t:[]}write(e,t){const n={},s=e;for(const i of this.fields){if(!i.oneof){let c=this.field(i,s[i.localName],t);c!==void 0&&(n[t.useProtoFieldName?i.name:i.jsonName]=c);continue}const o=s[i.oneof];if(o.oneofKind!==i.localName)continue;const a=i.kind=="scalar"||i.kind=="enum"?Object.assign(Object.assign({},t),{emitDefaultValues:!0}):t;let l=this.field(i,o[i.localName],a);p(l!==void 0),n[t.useProtoFieldName?i.name:i.jsonName]=l}return n}field(e,t,n){let s;if(e.kind=="map"){p(typeof t=="object"&&t!==null);const i={};switch(e.V.kind){case"scalar":for(const[l,c]of Object.entries(t)){const h=this.scalar(e.V.T,c,e.name,!1,!0);p(h!==void 0),i[l.toString()]=h}break;case"message":const o=e.V.T();for(const[l,c]of Object.entries(t)){const h=this.message(o,c,e.name,n);p(h!==void 0),i[l.toString()]=h}break;case"enum":const a=e.V.T();for(const[l,c]of Object.entries(t)){p(c===void 0||typeof c=="number");const h=this.enum(a,c,e.name,!1,!0,n.enumAsInteger);p(h!==void 0),i[l.toString()]=h}break}(n.emitDefaultValues||Object.keys(i).length>0)&&(s=i)}else if(e.repeat){p(Array.isArray(t));const i=[];switch(e.kind){case"scalar":for(let l=0;l0||n.emitDefaultValues)&&(s=i)}else switch(e.kind){case"scalar":s=this.scalar(e.T,t,e.name,e.opt,n.emitDefaultValues);break;case"enum":s=this.enum(e.T(),t,e.name,e.opt,n.emitDefaultValues,n.enumAsInteger);break;case"message":s=this.message(e.T(),t,e.name,n);break}return s}enum(e,t,n,s,i,o){if(e[0]=="google.protobuf.NullValue")return!i&&!s?void 0:null;if(t===void 0){p(s);return}if(!(t===0&&!i&&!s))return p(typeof t=="number"),p(Number.isInteger(t)),o||!e[1].hasOwnProperty(t)?t:e[2]?e[2]+e[1][t]:e[1][t]}message(e,t,n,s){return t===void 0?s.emitDefaultValues?null:void 0:e.internalJsonWrite(t,s)}scalar(e,t,n,s,i){if(t===void 0){p(s);return}const o=i||s;switch(e){case f.INT32:case f.SFIXED32:case f.SINT32:return t===0?o?0:void 0:(J(t),t);case f.FIXED32:case f.UINT32:return t===0?o?0:void 0:(Q(t),t);case f.FLOAT:he(t);case f.DOUBLE:return t===0?o?0:void 0:(p(typeof t=="number"),Number.isNaN(t)?"NaN":t===Number.POSITIVE_INFINITY?"Infinity":t===Number.NEGATIVE_INFINITY?"-Infinity":t);case f.STRING:return t===""?o?"":void 0:(p(typeof t=="string"),t);case f.BOOL:return t===!1?o?!1:void 0:(p(typeof t=="boolean"),t);case f.UINT64:case f.FIXED64:p(typeof t=="number"||typeof t=="string"||typeof t=="bigint");let a=D.from(t);return a.isZero()&&!o?void 0:a.toString();case f.INT64:case f.SFIXED64:case f.SINT64:p(typeof t=="number"||typeof t=="string"||typeof t=="bigint");let l=E.from(t);return l.isZero()&&!o?void 0:l.toString();case f.BYTES:return p(t instanceof Uint8Array),t.byteLength?Re(t):o?"":void 0}}}function fe(r,e=V.STRING){switch(r){case f.BOOL:return!1;case f.UINT64:case f.FIXED64:return _(D.ZERO,e);case f.INT64:case f.SFIXED64:case f.SINT64:return _(E.ZERO,e);case f.DOUBLE:case f.FLOAT:return 0;case f.BYTES:return new Uint8Array(0);case f.STRING:return"";default:return 0}}class ut{constructor(e){this.info=e}prepare(){var e;if(!this.fieldNoToField){const t=(e=this.info.fields)!==null&&e!==void 0?e:[];this.fieldNoToField=new Map(t.map(n=>[n.no,n]))}}read(e,t,n,s){this.prepare();const i=s===void 0?e.len:e.pos+s;for(;e.post.no-n.no)}}write(e,t,n){this.prepare();for(const i of this.fields){let o,a,l=i.repeat,c=i.localName;if(i.oneof){const h=e[i.oneof];if(h.oneofKind!==c)continue;o=h[c],a=!0}else o=e[c],a=!1;switch(i.kind){case"scalar":case"enum":let h=i.kind=="enum"?f.INT32:i.T;if(l)if(p(Array.isArray(o)),l==ee.PACKED)this.packed(t,h,i.no,o);else for(const m of o)this.scalar(t,h,i.no,m,!0);else o===void 0?p(i.opt):this.scalar(t,h,i.no,o,a||i.opt);break;case"message":if(l){p(Array.isArray(o));for(const m of o)this.message(t,n,i.T(),i.no,m)}else this.message(t,n,i.T(),i.no,o);break;case"map":p(typeof o=="object"&&o!==null);for(const[m,g]of Object.entries(o))this.mapEntry(t,n,i,m,g);break}}let s=n.writeUnknownFields;s!==!1&&(s===!0?F.onWrite:s)(this.info.typeName,e,t)}mapEntry(e,t,n,s,i){e.tag(n.no,y.LengthDelimited),e.fork();let o=s;switch(n.K){case f.INT32:case f.FIXED32:case f.UINT32:case f.SFIXED32:case f.SINT32:o=Number.parseInt(s);break;case f.BOOL:p(s=="true"||s=="false"),o=s=="true";break}switch(this.scalar(e,n.K,1,o,!0),n.V.kind){case"scalar":this.scalar(e,n.V.T,2,i,!0);break;case"enum":this.scalar(e,f.INT32,2,i,!0);break;case"message":this.message(e,t,n.V.T(),2,i);break}e.join()}message(e,t,n,s,i){i!==void 0&&(n.internalBinaryWrite(i,e.tag(s,y.LengthDelimited).fork(),t),e.join())}scalar(e,t,n,s,i){let[o,a,l]=this.scalarInfo(t,s);(!l||i)&&(e.tag(n,o),e[a](s))}packed(e,t,n,s){if(!s.length)return;p(t!==f.BYTES&&t!==f.STRING),e.tag(n,y.LengthDelimited),e.fork();let[,i]=this.scalarInfo(t);for(let o=0;ogt(s,this)),this.options=n??{}}}class N extends Error{constructor(e,t="UNKNOWN",n){super(e),this.name="RpcError",Object.setPrototypeOf(this,new.target.prototype),this.code=t,this.meta=n??{}}toString(){const e=[this.name+": "+this.message];this.code&&(e.push(""),e.push("Code: "+this.code)),this.serviceName&&this.methodName&&e.push("Method: "+this.serviceName+"/"+this.methodName);let t=Object.entries(this.meta);if(t.length){e.push(""),e.push("Meta:");for(let[n,s]of t)e.push(` ${n}: ${s}`)}return e.join(` +`)}}function Nt(r,e){if(!e)return r;let t={};H(r,t),H(e,t);for(let n of Object.keys(e)){let s=e[n];switch(n){case"jsonOptions":t.jsonOptions=it(r.jsonOptions,t.jsonOptions);break;case"binaryOptions":t.binaryOptions=Xe(r.binaryOptions,t.binaryOptions);break;case"meta":t.meta={},H(r.meta,t.meta),H(e.meta,t.meta);break;case"interceptors":t.interceptors=r.interceptors?r.interceptors.concat(s):s.concat();break}}return t}function H(r,e){if(!r)return;let t=e;for(let[n,s]of Object.entries(r))s instanceof Date?t[n]=new Date(s.getTime()):Array.isArray(s)?t[n]=s.concat():t[n]=s}var L;(function(r){r[r.PENDING=0]="PENDING",r[r.REJECTED=1]="REJECTED",r[r.RESOLVED=2]="RESOLVED"})(L||(L={}));class M{constructor(e=!0){this._state=L.PENDING,this._promise=new Promise((t,n)=>{this._resolve=t,this._reject=n}),e&&this._promise.catch(t=>{})}get state(){return this._state}get promise(){return this._promise}resolve(e){if(this.state!==L.PENDING)throw new Error(`cannot resolve ${L[this.state].toLowerCase()}`);this._resolve(e),this._state=L.RESOLVED}reject(e){if(this.state!==L.PENDING)throw new Error(`cannot reject ${L[this.state].toLowerCase()}`);this._reject(e),this._state=L.REJECTED}resolvePending(e){this._state===L.PENDING&&this.resolve(e)}rejectPending(e){this._state===L.PENDING&&this.reject(e)}}class wt{constructor(){this._lis={nxt:[],msg:[],err:[],cmp:[]},this._closed=!1,this._itState={q:[]}}onNext(e){return this.addLis(e,this._lis.nxt)}onMessage(e){return this.addLis(e,this._lis.msg)}onError(e){return this.addLis(e,this._lis.err)}onComplete(e){return this.addLis(e,this._lis.cmp)}addLis(e,t){return t.push(e),()=>{let n=t.indexOf(e);n>=0&&t.splice(n,1)}}clearLis(){for(let e of Object.values(this._lis))e.splice(0,e.length)}get closed(){return this._closed!==!1}notifyNext(e,t,n){p((e?1:0)+(t?1:0)+(n?1:0)<=1,"only one emission at a time"),e&&this.notifyMessage(e),t&&this.notifyError(t),n&&this.notifyComplete()}notifyMessage(e){p(!this.closed,"stream is closed"),this.pushIt({value:e,done:!1}),this._lis.msg.forEach(t=>t(e)),this._lis.nxt.forEach(t=>t(e,void 0,!1))}notifyError(e){p(!this.closed,"stream is closed"),this._closed=e,this.pushIt(e),this._lis.err.forEach(t=>t(e)),this._lis.nxt.forEach(t=>t(void 0,e,!1)),this.clearLis()}notifyComplete(){p(!this.closed,"stream is closed"),this._closed=!0,this.pushIt({value:null,done:!0}),this._lis.cmp.forEach(e=>e()),this._lis.nxt.forEach(e=>e(void 0,void 0,!0)),this.clearLis()}[Symbol.asyncIterator](){return this._closed===!0?this.pushIt({value:null,done:!0}):this._closed!==!1&&this.pushIt(this._closed),{next:()=>{let e=this._itState;p(e,"bad state"),p(!e.p,"iterator contract broken");let t=e.q.shift();return t?"value"in t?Promise.resolve(t):Promise.reject(t):(e.p=new M,e.p.promise)}}}pushIt(e){let t=this._itState;if(t.p){const n=t.p;p(n.state==L.PENDING,"iterator contract broken"),"value"in e?n.resolve(e):n.reject(e),delete t.p}else t.q.push(e)}}var yt=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};class Et{constructor(e,t,n,s,i,o,a){this.method=e,this.requestHeaders=t,this.request=n,this.headers=s,this.response=i,this.status=o,this.trailers=a}then(e,t){return this.promiseFinished().then(n=>e?Promise.resolve(e(n)):n,n=>t?Promise.resolve(t(n)):Promise.reject(n))}promiseFinished(){return yt(this,void 0,void 0,function*(){let[e,t,n,s]=yield Promise.all([this.headers,this.response,this.status,this.trailers]);return{method:this.method,requestHeaders:this.requestHeaders,request:this.request,headers:e,response:t,status:n,trailers:s}})}}var It=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};class Tt{constructor(e,t,n,s,i,o,a){this.method=e,this.requestHeaders=t,this.request=n,this.headers=s,this.responses=i,this.status=o,this.trailers=a}then(e,t){return this.promiseFinished().then(n=>e?Promise.resolve(e(n)):n,n=>t?Promise.resolve(t(n)):Promise.reject(n))}promiseFinished(){return It(this,void 0,void 0,function*(){let[e,t,n]=yield Promise.all([this.headers,this.status,this.trailers]);return{method:this.method,requestHeaders:this.requestHeaders,request:this.request,headers:e,status:t,trailers:n}})}}function ye(r,e,t,n,s){var i;{let o=(a,l,c)=>e.unary(a,l,c);for(const a of((i=n.interceptors)!==null&&i!==void 0?i:[]).filter(l=>l.interceptUnary).reverse()){const l=o;o=(c,h,m)=>a.interceptUnary(l,c,h,m)}return o(t,s,n)}}var u;(function(r){r[r.OK=0]="OK",r[r.CANCELLED=1]="CANCELLED",r[r.UNKNOWN=2]="UNKNOWN",r[r.INVALID_ARGUMENT=3]="INVALID_ARGUMENT",r[r.DEADLINE_EXCEEDED=4]="DEADLINE_EXCEEDED",r[r.NOT_FOUND=5]="NOT_FOUND",r[r.ALREADY_EXISTS=6]="ALREADY_EXISTS",r[r.PERMISSION_DENIED=7]="PERMISSION_DENIED",r[r.UNAUTHENTICATED=16]="UNAUTHENTICATED",r[r.RESOURCE_EXHAUSTED=8]="RESOURCE_EXHAUSTED",r[r.FAILED_PRECONDITION=9]="FAILED_PRECONDITION",r[r.ABORTED=10]="ABORTED",r[r.OUT_OF_RANGE=11]="OUT_OF_RANGE",r[r.UNIMPLEMENTED=12]="UNIMPLEMENTED",r[r.INTERNAL=13]="INTERNAL",r[r.UNAVAILABLE=14]="UNAVAILABLE",r[r.DATA_LOSS=15]="DATA_LOSS"})(u||(u={}));var Ot=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};function Ee(r,e,t,n,s){if(n)for(let[i,o]of Object.entries(n))if(typeof o=="string")r.append(i,o);else for(let a of o)r.append(i,a);if(r.set("Content-Type",e==="text"?"application/grpc-web-text":"application/grpc-web+proto"),e=="text"&&r.set("Accept","application/grpc-web-text"),r.set("X-Grpc-Web","1"),typeof t=="number"){if(t<=0)throw new N(`timeout ${t} ms exceeded`,u[u.DEADLINE_EXCEEDED]);r.set("grpc-timeout",`${t}m`)}else if(t){const i=t.getTime(),o=Date.now();if(i<=o)throw new N(`deadline ${t} exceeded`,u[u.DEADLINE_EXCEEDED]);r.set("grpc-timeout",`${i-o}m`)}return r}function Ie(r,e){let t=new Uint8Array(5+r.length);t[0]=U.DATA;for(let n=r.length,s=4;s>0;s--)t[s]=n%256,n>>>=8;return t.set(r,5),e==="binary"?t:Re(t)}function ue(r,e,t){if(arguments.length===1){let l=r,c;try{c=l.type}catch{}switch(c){case"error":case"opaque":case"opaqueredirect":throw new N(`fetch response type ${l.type}`,u[u.UNKNOWN])}return ue(Rt(l.headers),l.status,l.statusText)}let n=r,s=e>=200&&e<300,i=ve(n),[o,a]=Ve(n);return(o===void 0||o===u.OK)&&!s&&(o=Bt(e),a=t),[o,a,i]}function Te(r){let e=Dt(r),[t,n]=Ve(e),s=ve(e);return[t??u.OK,n,s]}var U;(function(r){r[r.DATA=0]="DATA",r[r.TRAILER=128]="TRAILER"})(U||(U={}));function Oe(r,e,t){return Ot(this,void 0,void 0,function*(){let n,s="",i=new Uint8Array(0),o=At(e);if(kt(r)){let a=r.getReader();n={next:()=>a.read()}}else n=r[Symbol.asyncIterator]();for(;;){let a=yield n.next();if(a.value!==void 0){if(o==="text"){for(let c=0;c=5&&i[0]===U.DATA;){let l=0;for(let c=1;c<5;c++)l=(l<<8)+i[c];if(i.length-5>=l)t(U.DATA,i.subarray(5,5+l)),i=i.subarray(5+l);else break}}if(a.done){if(i.length===0)break;if(i[0]!==U.TRAILER||i.length<5)throw new N("premature EOF",u[u.DATA_LOSS]);t(U.TRAILER,i.subarray(5));break}}})}const kt=r=>typeof r.getReader=="function";function ke(r,e){let t=new Uint8Array(r.length+e.length);return t.set(r),t.set(e,r.length),t}function At(r){switch(r){case"application/grpc-web-text":case"application/grpc-web-text+proto":return"text";case"application/grpc-web":case"application/grpc-web+proto":return"binary";case void 0:case null:throw new N("missing response content type",u[u.INTERNAL]);default:throw new N("unexpected response content type: "+r,u[u.INTERNAL])}}function Ve(r){let e,t,n=r["grpc-message"];if(n!==void 0){if(Array.isArray(n))return[u.INTERNAL,"invalid grpc-web message"];t=n}let s=r["grpc-status"];if(s!==void 0){if(Array.isArray(s))return[u.INTERNAL,"invalid grpc-web status"];if(e=parseInt(s,10),u[e]===void 0)return[u.INTERNAL,"invalid grpc-web status"]}return[e,t]}function ve(r){let e={};for(let[t,n]of Object.entries(r))switch(t){case"grpc-message":case"grpc-status":case"content-type":break;default:e[t]=n}return e}function Dt(r){let e={};for(let t of String.fromCharCode.apply(String,r).trim().split(`\r +`)){if(t=="")continue;let[n,...s]=t.split(":");const i=s.join(":").trim();n=n.trim();let o=e[n];typeof o=="string"?e[n]=[o,i]:Array.isArray(o)?o.push(i):e[n]=i}return e}function Rt(r){let e={};return r.forEach((t,n)=>{let s=e[n];typeof s=="string"?e[n]=[s,t]:Array.isArray(s)?s.push(t):e[n]=t}),e}function Bt(r){switch(r){case 200:return u.OK;case 400:return u.INVALID_ARGUMENT;case 401:return u.UNAUTHENTICATED;case 403:return u.PERMISSION_DENIED;case 404:return u.NOT_FOUND;case 409:return u.ABORTED;case 412:return u.FAILED_PRECONDITION;case 429:return u.RESOURCE_EXHAUSTED;case 499:return u.CANCELLED;case 500:return u.UNKNOWN;case 501:return u.UNIMPLEMENTED;case 503:return u.UNAVAILABLE;case 504:return u.DEADLINE_EXCEEDED;default:return u.UNKNOWN}}class Lt{constructor(e){this.defaultOptions=e}mergeOptions(e){return Nt(this.defaultOptions,e)}makeUrl(e,t){let n=t.baseUrl;return n.endsWith("/")&&(n=n.substring(0,n.length-1)),`${n}/${e.service.typeName}/${e.name}`}clientStreaming(e){const t=new N("Client streaming is not supported by grpc-web",u[u.UNIMPLEMENTED]);throw t.methodName=e.name,t.serviceName=e.service.typeName,t}duplex(e){const t=new N("Duplex streaming is not supported by grpc-web",u[u.UNIMPLEMENTED]);throw t.methodName=e.name,t.serviceName=e.service.typeName,t}serverStreaming(e,t,n){var s,i,o,a,l;let c=n,h=(s=c.format)!==null&&s!==void 0?s:"text",m=(i=c.fetch)!==null&&i!==void 0?i:globalThis.fetch,g=(o=c.fetchInit)!==null&&o!==void 0?o:{},O=this.makeUrl(e,c),R=e.I.toBinary(t,c.binaryOptions),v=new M,k=new wt,S=!0,T,C=new M,x,K=new M;return m(O,Object.assign(Object.assign({},g),{method:"POST",headers:Ee(new globalThis.Headers,h,c.timeout,c.meta),body:Ie(R,h),signal:(a=n.abort)!==null&&a!==void 0?a:null})).then(b=>{let[d,I,B]=ue(b);if(v.resolve(B),d!=null&&d!==u.OK)throw new N(I??u[d],u[d],B);return d!=null&&(T={code:u[d],detail:I??u[d]}),b}).then(b=>{if(!b.body)throw new N("missing response body",u[u.INTERNAL]);return Oe(b.body,b.headers.get("content-type"),(d,I)=>{switch(d){case U.DATA:k.notifyMessage(e.O.fromBinary(I,c.binaryOptions)),S=!1;break;case U.TRAILER:let B,P;[B,P,x]=Te(I),T={code:u[B],detail:P??u[B]};break}})}).then(()=>{if(!x&&!S)throw new N("missing trailers",u[u.DATA_LOSS]);if(!T)throw new N("missing status",u[u.INTERNAL]);if(T.code!=="OK")throw new N(T.detail,T.code,x);k.notifyComplete(),C.resolve(T),K.resolve(x||{})}).catch(b=>{let d;b instanceof N?d=b:b instanceof Error&&b.name==="AbortError"?d=new N(b.message,u[u.CANCELLED]):d=new N(b instanceof Error?b.message:""+b,u[u.INTERNAL]),d.methodName=e.name,d.serviceName=e.service.typeName,v.rejectPending(d),k.notifyError(d),C.rejectPending(d),K.rejectPending(d)}),new Tt(e,(l=c.meta)!==null&&l!==void 0?l:{},t,v.promise,k,C.promise,K.promise)}unary(e,t,n){var s,i,o,a,l;let c=n,h=(s=c.format)!==null&&s!==void 0?s:"text",m=(i=c.fetch)!==null&&i!==void 0?i:globalThis.fetch,g=(o=c.fetchInit)!==null&&o!==void 0?o:{},O=this.makeUrl(e,c),R=e.I.toBinary(t,c.binaryOptions),v=new M,k,S=new M,T,C=new M,x,K=new M;return m(O,Object.assign(Object.assign({},g),{method:"POST",headers:Ee(new globalThis.Headers,h,c.timeout,c.meta),body:Ie(R,h),signal:(a=n.abort)!==null&&a!==void 0?a:null})).then(b=>{let[d,I,B]=ue(b);if(v.resolve(B),d!=null&&d!==u.OK)throw new N(I??u[d],u[d],B);return d!=null&&(T={code:u[d],detail:I??u[d]}),b}).then(b=>{if(!b.body)throw new N("missing response body",u[u.INTERNAL]);return Oe(b.body,b.headers.get("content-type"),(d,I)=>{switch(d){case U.DATA:if(k)throw new N("unary call received 2nd message",u[u.DATA_LOSS]);k=e.O.fromBinary(I,c.binaryOptions);break;case U.TRAILER:let B,P;[B,P,x]=Te(I),T={code:u[B],detail:P??u[B]};break}})}).then(()=>{if(!x&&k)throw new N("missing trailers",u[u.DATA_LOSS]);if(!T)throw new N("missing status",u[u.INTERNAL]);if(!k&&T.code==="OK")throw new N("expected error status",u[u.DATA_LOSS]);if(!k)throw new N(T.detail,T.code,x);if(S.resolve(k),T.code!=="OK")throw new N(T.detail,T.code,x);C.resolve(T),K.resolve(x||{})}).catch(b=>{let d;b instanceof N?d=b:b instanceof Error&&b.name==="AbortError"?d=new N(b.message,u[u.CANCELLED]):d=new N(b instanceof Error?b.message:""+b,u[u.INTERNAL]),d.methodName=e.name,d.serviceName=e.service.typeName,v.rejectPending(d),S.rejectPending(d),C.rejectPending(d),K.rejectPending(d)}),new Et(e,(l=c.meta)!==null&&l!==void 0?l:{},t,v.promise,S.promise,C.promise,K.promise)}}class Ft extends se{constructor(){super("grpcweb.example.v1.HelloRequest",[{no:1,name:"name",kind:"scalar",T:9}])}create(e){const t=globalThis.Object.create(this.messagePrototype);return t.name="",e!==void 0&&X(this,t,e),t}internalBinaryRead(e,t,n,s){let i=s??this.create(),o=e.pos+t;for(;e.pos50&&Z.shift(),ae.textContent=Z.join(` +`),ae.scrollTop=ae.scrollHeight}function j(r,e,t=!1){r.textContent=typeof e=="object"?JSON.stringify(e,null,2):String(e),r.className="result "+(t?"error":"success")}async function Jt(){const r=$e.value||"Anonymous";A(`[HTTP/JSON] 发送请求到 /v1/greeter/hello,name=${r}`);try{const t=await(await fetch("/v1/greeter/hello",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:r})})).json();A(`[HTTP/JSON] 收到响应: ${JSON.stringify(t)}`),j(te,t)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[HTTP/JSON] 错误: ${t}`),j(te,t,!0)}}async function Wt(){const r=Me.value||"Anonymous";A(`[HTTP/JSON] 发送请求到 /v1/greeter/goodbye,name=${r}`);try{const t=await(await fetch("/v1/greeter/goodbye",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:r})})).json();A(`[HTTP/JSON] 收到响应: ${JSON.stringify(t)}`),j(ne,t)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[HTTP/JSON] 错误: ${t}`),j(ne,t,!0)}}async function Gt(){const r=$e.value||"Anonymous";A(`[gRPC Web] 调用 GreeterService.SayHello,name=${r}`);try{const e=Pe.sayHello({name:r}),t=await e.response;A(`[gRPC Web] 收到响应: message=${t.message}, timestamp=${t.timestamp}`),j(te,{message:t.message,timestamp:t.timestamp.toString()});const n=await e.status;A(`[gRPC Web] 状态: ${n.code}`)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[gRPC Web] 错误: ${t}`),j(te,t,!0)}}async function qt(){const r=Me.value||"Anonymous";A(`[gRPC Web] 调用 GreeterService.SayGoodbye,name=${r}`);try{const e=Pe.sayGoodbye({name:r}),t=await e.response;A(`[gRPC Web] 收到响应: message=${t.message}, timestamp=${t.timestamp}`),j(ne,{message:t.message,timestamp:t.timestamp.toString()});const n=await e.status;A(`[gRPC Web] 状态: ${n.code}`)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[gRPC Web] 错误: ${t}`),j(ne,t,!0)}}jt.addEventListener("click",Jt);Ct.addEventListener("click",Gt);Kt.addEventListener("click",Wt);Xt.addEventListener("click",qt);A("页面加载完成,protobuf-ts gRPC Web 客户端已初始化");A(`Transport: GrpcWebFetchTransport, baseUrl: ${window.location.origin}`); diff --git a/internal/examples/grpcweb/frontend/dist/index.html b/internal/examples/grpcweb/frontend/dist/index.html new file mode 100644 index 000000000..8e211ed7b --- /dev/null +++ b/internal/examples/grpcweb/frontend/dist/index.html @@ -0,0 +1,168 @@ + + + + + + gRPC Web 测试 - protobuf-ts + + + + +

🌐 gRPC Web 测试工具 protobuf-ts

+ +
+ 说明: 本页面使用 @protobuf-ts/grpcweb-transport 实现 gRPC Web 调用。 +

+ HTTP/JSON 模式: 使用 application/json Content-Type,通过 REST API 调用 +
+ gRPC Web 模式: 使用 application/grpc-web+proto Content-Type,通过 protobuf-ts 客户端调用 +
+ +
+
+

👋 SayHello 测试

+ + + + +
等待测试...
+
+ +
+

👋 SayGoodbye 测试

+ + + + +
等待测试...
+
+ +
+

📋 请求日志

+
+
+
+ + + diff --git a/internal/examples/grpcweb/frontend/index.html b/internal/examples/grpcweb/frontend/index.html new file mode 100644 index 000000000..9e9abed9c --- /dev/null +++ b/internal/examples/grpcweb/frontend/index.html @@ -0,0 +1,168 @@ + + + + + + gRPC Web 测试 - protobuf-ts + + + +

🌐 gRPC Web 测试工具 protobuf-ts

+ +
+ 说明: 本页面使用 @protobuf-ts/grpcweb-transport 实现 gRPC Web 调用。 +

+ HTTP/JSON 模式: 使用 application/json Content-Type,通过 REST API 调用 +
+ gRPC Web 模式: 使用 application/grpc-web+proto Content-Type,通过 protobuf-ts 客户端调用 +
+ +
+
+

👋 SayHello 测试

+ + + + +
等待测试...
+
+ +
+

👋 SayGoodbye 测试

+ + + + +
等待测试...
+
+ +
+

📋 请求日志

+
+
+
+ + + + diff --git a/internal/examples/grpcweb/frontend/package-lock.json b/internal/examples/grpcweb/frontend/package-lock.json new file mode 100644 index 000000000..13fc165d8 --- /dev/null +++ b/internal/examples/grpcweb/frontend/package-lock.json @@ -0,0 +1,1840 @@ +{ + "name": "grpcweb-example-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "grpcweb-example-frontend", + "version": "1.0.0", + "dependencies": { + "@protobuf-ts/grpcweb-transport": "^2.11.1", + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1" + }, + "devDependencies": { + "@protobuf-ts/plugin": "^2.11.1", + "protoc": "^1.0.0", + "typescript": "^5.8.3", + "vite": "^6.0.7" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.11.0.tgz", + "integrity": "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@protobuf-ts/grpcweb-transport": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.11.1.tgz", + "integrity": "sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1" + } + }, + "node_modules/@protobuf-ts/plugin": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/plugin/-/plugin-2.11.1.tgz", + "integrity": "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^2.4.0", + "@bufbuild/protoplugin": "^2.4.0", + "@protobuf-ts/protoc": "^2.11.1", + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1", + "typescript": "^3.9" + }, + "bin": { + "protoc-gen-dump": "bin/protoc-gen-dump", + "protoc-gen-ts": "bin/protoc-gen-ts" + } + }, + "node_modules/@protobuf-ts/plugin/node_modules/typescript": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", + "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/@protobuf-ts/protoc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz", + "integrity": "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "protoc": "protoc.js" + } + }, + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@protobuf-ts/runtime-rpc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz", + "integrity": "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/vfs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.2.tgz", + "integrity": "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "dev": true, + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "dev": true, + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", + "integrity": "sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/clone-stats": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", + "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/cloneable-readable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", + "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "process-nextick-args": "^2.0.0", + "readable-stream": "^2.3.5" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/protoc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/protoc/-/protoc-1.1.3.tgz", + "integrity": "sha512-Vy4OBxCcF0W38YrZZRFix659gFu8ujIxVDP1SUBK9ELzyeMSBe8m8tYyYlX1PI5j9gse9hWu4c4nzQaHesAf8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "mkdirp": "^0.5.6", + "node-fetch": "^3.2.10", + "rimraf": "^3.0.2", + "unzipper": "^0.10.11", + "uuid": "^9.0.0", + "vinyl": "^2.2.1" + }, + "bin": { + "protoc": "bin/protoc" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "dev": true, + "license": "ISC" + }, + "node_modules/replace-ext": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", + "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "dev": true, + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vinyl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", + "integrity": "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^2.1.1", + "clone-buffer": "^1.0.0", + "clone-stats": "^1.0.0", + "cloneable-readable": "^1.0.0", + "remove-trailing-separator": "^1.0.1", + "replace-ext": "^1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/internal/examples/grpcweb/frontend/package.json b/internal/examples/grpcweb/frontend/package.json new file mode 100644 index 000000000..91c06ee3a --- /dev/null +++ b/internal/examples/grpcweb/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "grpcweb-example-frontend", + "version": "1.0.0", + "description": "gRPC Web Example Frontend with protobuf-ts", + "type": "module", + "scripts": { + "generate": "npx protoc --ts_out . --proto_path ../proto ../proto/greeter.proto", + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@protobuf-ts/plugin": "^2.11.1", + "protoc": "^1.0.0", + "typescript": "^5.8.3", + "vite": "^6.0.7" + }, + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1", + "@protobuf-ts/grpcweb-transport": "^2.11.1" + } +} diff --git a/internal/examples/grpcweb/frontend/src/generated/google/protobuf/descriptor.ts b/internal/examples/grpcweb/frontend/src/generated/google/protobuf/descriptor.ts new file mode 100644 index 000000000..620064675 --- /dev/null +++ b/internal/examples/grpcweb/frontend/src/generated/google/protobuf/descriptor.ts @@ -0,0 +1,4654 @@ +// @generated by protobuf-ts 2.11.1 +// @generated from protobuf file "google/protobuf/descriptor.proto" (package "google.protobuf", syntax proto2) +// tslint:disable +// +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +// +// Author: kenton@google.com (Kenton Varda) +// Based on original Protocol Buffers design by +// Sanjay Ghemawat, Jeff Dean, and others. +// +// The messages in this file describe the definitions found in .proto files. +// A valid .proto file can be translated directly to a FileDescriptorProto +// without any other information (e.g. without reading its imports). +// +import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; +import type { IBinaryWriter } from "@protobuf-ts/runtime"; +import { WireType } from "@protobuf-ts/runtime"; +import type { BinaryReadOptions } from "@protobuf-ts/runtime"; +import type { IBinaryReader } from "@protobuf-ts/runtime"; +import { UnknownFieldHandler } from "@protobuf-ts/runtime"; +import type { PartialMessage } from "@protobuf-ts/runtime"; +import { reflectionMergePartial } from "@protobuf-ts/runtime"; +import { MessageType } from "@protobuf-ts/runtime"; +/** + * The protocol compiler can output a FileDescriptorSet containing the .proto + * files it parses. + * + * @generated from protobuf message google.protobuf.FileDescriptorSet + */ +export interface FileDescriptorSet { + /** + * @generated from protobuf field: repeated google.protobuf.FileDescriptorProto file = 1 + */ + file: FileDescriptorProto[]; +} +/** + * Describes a complete .proto file. + * + * @generated from protobuf message google.protobuf.FileDescriptorProto + */ +export interface FileDescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; // file name, relative to root of source tree + /** + * @generated from protobuf field: optional string package = 2 + */ + package?: string; // e.g. "foo", "foo.bar", etc. + /** + * Names of files imported by this file. + * + * @generated from protobuf field: repeated string dependency = 3 + */ + dependency: string[]; + /** + * Indexes of the public imported files in the dependency list above. + * + * @generated from protobuf field: repeated int32 public_dependency = 10 + */ + publicDependency: number[]; + /** + * Indexes of the weak imported files in the dependency list. + * For Google-internal migration only. Do not use. + * + * @generated from protobuf field: repeated int32 weak_dependency = 11 + */ + weakDependency: number[]; + /** + * All top-level definitions in this file. + * + * @generated from protobuf field: repeated google.protobuf.DescriptorProto message_type = 4 + */ + messageType: DescriptorProto[]; + /** + * @generated from protobuf field: repeated google.protobuf.EnumDescriptorProto enum_type = 5 + */ + enumType: EnumDescriptorProto[]; + /** + * @generated from protobuf field: repeated google.protobuf.ServiceDescriptorProto service = 6 + */ + service: ServiceDescriptorProto[]; + /** + * @generated from protobuf field: repeated google.protobuf.FieldDescriptorProto extension = 7 + */ + extension: FieldDescriptorProto[]; + /** + * @generated from protobuf field: optional google.protobuf.FileOptions options = 8 + */ + options?: FileOptions; + /** + * This field contains optional information about the original source code. + * You may safely remove this entire field without harming runtime + * functionality of the descriptors -- the information is needed only by + * development tools. + * + * @generated from protobuf field: optional google.protobuf.SourceCodeInfo source_code_info = 9 + */ + sourceCodeInfo?: SourceCodeInfo; + /** + * The syntax of the proto file. + * The supported values are "proto2", "proto3", and "editions". + * + * If `edition` is present, this value must be "editions". + * + * @generated from protobuf field: optional string syntax = 12 + */ + syntax?: string; + /** + * The edition of the proto file. + * + * @generated from protobuf field: optional google.protobuf.Edition edition = 14 + */ + edition?: Edition; +} +/** + * Describes a message type. + * + * @generated from protobuf message google.protobuf.DescriptorProto + */ +export interface DescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; + /** + * @generated from protobuf field: repeated google.protobuf.FieldDescriptorProto field = 2 + */ + field: FieldDescriptorProto[]; + /** + * @generated from protobuf field: repeated google.protobuf.FieldDescriptorProto extension = 6 + */ + extension: FieldDescriptorProto[]; + /** + * @generated from protobuf field: repeated google.protobuf.DescriptorProto nested_type = 3 + */ + nestedType: DescriptorProto[]; + /** + * @generated from protobuf field: repeated google.protobuf.EnumDescriptorProto enum_type = 4 + */ + enumType: EnumDescriptorProto[]; + /** + * @generated from protobuf field: repeated google.protobuf.DescriptorProto.ExtensionRange extension_range = 5 + */ + extensionRange: DescriptorProto_ExtensionRange[]; + /** + * @generated from protobuf field: repeated google.protobuf.OneofDescriptorProto oneof_decl = 8 + */ + oneofDecl: OneofDescriptorProto[]; + /** + * @generated from protobuf field: optional google.protobuf.MessageOptions options = 7 + */ + options?: MessageOptions; + /** + * @generated from protobuf field: repeated google.protobuf.DescriptorProto.ReservedRange reserved_range = 9 + */ + reservedRange: DescriptorProto_ReservedRange[]; + /** + * Reserved field names, which may not be used by fields in the same message. + * A given name may only be reserved once. + * + * @generated from protobuf field: repeated string reserved_name = 10 + */ + reservedName: string[]; +} +/** + * @generated from protobuf message google.protobuf.DescriptorProto.ExtensionRange + */ +export interface DescriptorProto_ExtensionRange { + /** + * @generated from protobuf field: optional int32 start = 1 + */ + start?: number; // Inclusive. + /** + * @generated from protobuf field: optional int32 end = 2 + */ + end?: number; // Exclusive. + /** + * @generated from protobuf field: optional google.protobuf.ExtensionRangeOptions options = 3 + */ + options?: ExtensionRangeOptions; +} +/** + * Range of reserved tag numbers. Reserved tag numbers may not be used by + * fields or extension ranges in the same message. Reserved ranges may + * not overlap. + * + * @generated from protobuf message google.protobuf.DescriptorProto.ReservedRange + */ +export interface DescriptorProto_ReservedRange { + /** + * @generated from protobuf field: optional int32 start = 1 + */ + start?: number; // Inclusive. + /** + * @generated from protobuf field: optional int32 end = 2 + */ + end?: number; // Exclusive. +} +/** + * @generated from protobuf message google.protobuf.ExtensionRangeOptions + */ +export interface ExtensionRangeOptions { + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; + /** + * For external users: DO NOT USE. We are in the process of open sourcing + * extension declaration and executing internal cleanups before it can be + * used externally. + * + * @generated from protobuf field: repeated google.protobuf.ExtensionRangeOptions.Declaration declaration = 2 + */ + declaration: ExtensionRangeOptions_Declaration[]; + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 50 + */ + features?: FeatureSet; + /** + * The verification state of the range. + * TODO: flip the default to DECLARATION once all empty ranges + * are marked as UNVERIFIED. + * + * @generated from protobuf field: optional google.protobuf.ExtensionRangeOptions.VerificationState verification = 3 [default = UNVERIFIED] + */ + verification?: ExtensionRangeOptions_VerificationState; +} +/** + * @generated from protobuf message google.protobuf.ExtensionRangeOptions.Declaration + */ +export interface ExtensionRangeOptions_Declaration { + /** + * The extension number declared within the extension range. + * + * @generated from protobuf field: optional int32 number = 1 + */ + number?: number; + /** + * The fully-qualified name of the extension field. There must be a leading + * dot in front of the full name. + * + * @generated from protobuf field: optional string full_name = 2 + */ + fullName?: string; + /** + * The fully-qualified type name of the extension field. Unlike + * Metadata.type, Declaration.type must have a leading dot for messages + * and enums. + * + * @generated from protobuf field: optional string type = 3 + */ + type?: string; + /** + * If true, indicates that the number is reserved in the extension range, + * and any extension field with the number will fail to compile. Set this + * when a declared extension field is deleted. + * + * @generated from protobuf field: optional bool reserved = 5 + */ + reserved?: boolean; + /** + * If true, indicates that the extension must be defined as repeated. + * Otherwise the extension must be defined as optional. + * + * @generated from protobuf field: optional bool repeated = 6 + */ + repeated?: boolean; +} +/** + * The verification state of the extension range. + * + * @generated from protobuf enum google.protobuf.ExtensionRangeOptions.VerificationState + */ +export enum ExtensionRangeOptions_VerificationState { + /** + * All the extensions of the range must be declared. + * + * @generated from protobuf enum value: DECLARATION = 0; + */ + DECLARATION = 0, + /** + * @generated from protobuf enum value: UNVERIFIED = 1; + */ + UNVERIFIED = 1 +} +/** + * Describes a field within a message. + * + * @generated from protobuf message google.protobuf.FieldDescriptorProto + */ +export interface FieldDescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; + /** + * @generated from protobuf field: optional int32 number = 3 + */ + number?: number; + /** + * @generated from protobuf field: optional google.protobuf.FieldDescriptorProto.Label label = 4 + */ + label?: FieldDescriptorProto_Label; + /** + * If type_name is set, this need not be set. If both this and type_name + * are set, this must be one of TYPE_ENUM, TYPE_MESSAGE or TYPE_GROUP. + * + * @generated from protobuf field: optional google.protobuf.FieldDescriptorProto.Type type = 5 + */ + type?: FieldDescriptorProto_Type; + /** + * For message and enum types, this is the name of the type. If the name + * starts with a '.', it is fully-qualified. Otherwise, C++-like scoping + * rules are used to find the type (i.e. first the nested types within this + * message are searched, then within the parent, on up to the root + * namespace). + * + * @generated from protobuf field: optional string type_name = 6 + */ + typeName?: string; + /** + * For extensions, this is the name of the type being extended. It is + * resolved in the same manner as type_name. + * + * @generated from protobuf field: optional string extendee = 2 + */ + extendee?: string; + /** + * For numeric types, contains the original text representation of the value. + * For booleans, "true" or "false". + * For strings, contains the default text contents (not escaped in any way). + * For bytes, contains the C escaped value. All bytes >= 128 are escaped. + * + * @generated from protobuf field: optional string default_value = 7 + */ + defaultValue?: string; + /** + * If set, gives the index of a oneof in the containing type's oneof_decl + * list. This field is a member of that oneof. + * + * @generated from protobuf field: optional int32 oneof_index = 9 + */ + oneofIndex?: number; + /** + * JSON name of this field. The value is set by protocol compiler. If the + * user has set a "json_name" option on this field, that option's value + * will be used. Otherwise, it's deduced from the field's name by converting + * it to camelCase. + * + * @generated from protobuf field: optional string json_name = 10 + */ + jsonName?: string; + /** + * @generated from protobuf field: optional google.protobuf.FieldOptions options = 8 + */ + options?: FieldOptions; + /** + * If true, this is a proto3 "optional". When a proto3 field is optional, it + * tracks presence regardless of field type. + * + * When proto3_optional is true, this field must belong to a oneof to signal + * to old proto3 clients that presence is tracked for this field. This oneof + * is known as a "synthetic" oneof, and this field must be its sole member + * (each proto3 optional field gets its own synthetic oneof). Synthetic oneofs + * exist in the descriptor only, and do not generate any API. Synthetic oneofs + * must be ordered after all "real" oneofs. + * + * For message fields, proto3_optional doesn't create any semantic change, + * since non-repeated message fields always track presence. However it still + * indicates the semantic detail of whether the user wrote "optional" or not. + * This can be useful for round-tripping the .proto file. For consistency we + * give message fields a synthetic oneof also, even though it is not required + * to track presence. This is especially important because the parser can't + * tell if a field is a message or an enum, so it must always create a + * synthetic oneof. + * + * Proto2 optional fields do not set this flag, because they already indicate + * optional with `LABEL_OPTIONAL`. + * + * @generated from protobuf field: optional bool proto3_optional = 17 + */ + proto3Optional?: boolean; +} +/** + * @generated from protobuf enum google.protobuf.FieldDescriptorProto.Type + */ +export enum FieldDescriptorProto_Type { + /** + * @generated synthetic value - protobuf-ts requires all enums to have a 0 value + */ + UNSPECIFIED$ = 0, + /** + * 0 is reserved for errors. + * Order is weird for historical reasons. + * + * @generated from protobuf enum value: TYPE_DOUBLE = 1; + */ + DOUBLE = 1, + /** + * @generated from protobuf enum value: TYPE_FLOAT = 2; + */ + FLOAT = 2, + /** + * Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT64 if + * negative values are likely. + * + * @generated from protobuf enum value: TYPE_INT64 = 3; + */ + INT64 = 3, + /** + * @generated from protobuf enum value: TYPE_UINT64 = 4; + */ + UINT64 = 4, + /** + * Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT32 if + * negative values are likely. + * + * @generated from protobuf enum value: TYPE_INT32 = 5; + */ + INT32 = 5, + /** + * @generated from protobuf enum value: TYPE_FIXED64 = 6; + */ + FIXED64 = 6, + /** + * @generated from protobuf enum value: TYPE_FIXED32 = 7; + */ + FIXED32 = 7, + /** + * @generated from protobuf enum value: TYPE_BOOL = 8; + */ + BOOL = 8, + /** + * @generated from protobuf enum value: TYPE_STRING = 9; + */ + STRING = 9, + /** + * Tag-delimited aggregate. + * Group type is deprecated and not supported after google.protobuf. However, Proto3 + * implementations should still be able to parse the group wire format and + * treat group fields as unknown fields. In Editions, the group wire format + * can be enabled via the `message_encoding` feature. + * + * @generated from protobuf enum value: TYPE_GROUP = 10; + */ + GROUP = 10, + /** + * Length-delimited aggregate. + * + * @generated from protobuf enum value: TYPE_MESSAGE = 11; + */ + MESSAGE = 11, + /** + * New in version 2. + * + * @generated from protobuf enum value: TYPE_BYTES = 12; + */ + BYTES = 12, + /** + * @generated from protobuf enum value: TYPE_UINT32 = 13; + */ + UINT32 = 13, + /** + * @generated from protobuf enum value: TYPE_ENUM = 14; + */ + ENUM = 14, + /** + * @generated from protobuf enum value: TYPE_SFIXED32 = 15; + */ + SFIXED32 = 15, + /** + * @generated from protobuf enum value: TYPE_SFIXED64 = 16; + */ + SFIXED64 = 16, + /** + * Uses ZigZag encoding. + * + * @generated from protobuf enum value: TYPE_SINT32 = 17; + */ + SINT32 = 17, + /** + * Uses ZigZag encoding. + * + * @generated from protobuf enum value: TYPE_SINT64 = 18; + */ + SINT64 = 18 +} +/** + * @generated from protobuf enum google.protobuf.FieldDescriptorProto.Label + */ +export enum FieldDescriptorProto_Label { + /** + * @generated synthetic value - protobuf-ts requires all enums to have a 0 value + */ + UNSPECIFIED$ = 0, + /** + * 0 is reserved for errors + * + * @generated from protobuf enum value: LABEL_OPTIONAL = 1; + */ + OPTIONAL = 1, + /** + * @generated from protobuf enum value: LABEL_REPEATED = 3; + */ + REPEATED = 3, + /** + * The required label is only allowed in google.protobuf. In proto3 and Editions + * it's explicitly prohibited. In Editions, the `field_presence` feature + * can be used to get this behavior. + * + * @generated from protobuf enum value: LABEL_REQUIRED = 2; + */ + REQUIRED = 2 +} +/** + * Describes a oneof. + * + * @generated from protobuf message google.protobuf.OneofDescriptorProto + */ +export interface OneofDescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; + /** + * @generated from protobuf field: optional google.protobuf.OneofOptions options = 2 + */ + options?: OneofOptions; +} +/** + * Describes an enum type. + * + * @generated from protobuf message google.protobuf.EnumDescriptorProto + */ +export interface EnumDescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; + /** + * @generated from protobuf field: repeated google.protobuf.EnumValueDescriptorProto value = 2 + */ + value: EnumValueDescriptorProto[]; + /** + * @generated from protobuf field: optional google.protobuf.EnumOptions options = 3 + */ + options?: EnumOptions; + /** + * Range of reserved numeric values. Reserved numeric values may not be used + * by enum values in the same enum declaration. Reserved ranges may not + * overlap. + * + * @generated from protobuf field: repeated google.protobuf.EnumDescriptorProto.EnumReservedRange reserved_range = 4 + */ + reservedRange: EnumDescriptorProto_EnumReservedRange[]; + /** + * Reserved enum value names, which may not be reused. A given name may only + * be reserved once. + * + * @generated from protobuf field: repeated string reserved_name = 5 + */ + reservedName: string[]; +} +/** + * Range of reserved numeric values. Reserved values may not be used by + * entries in the same enum. Reserved ranges may not overlap. + * + * Note that this is distinct from DescriptorProto.ReservedRange in that it + * is inclusive such that it can appropriately represent the entire int32 + * domain. + * + * @generated from protobuf message google.protobuf.EnumDescriptorProto.EnumReservedRange + */ +export interface EnumDescriptorProto_EnumReservedRange { + /** + * @generated from protobuf field: optional int32 start = 1 + */ + start?: number; // Inclusive. + /** + * @generated from protobuf field: optional int32 end = 2 + */ + end?: number; // Inclusive. +} +/** + * Describes a value within an enum. + * + * @generated from protobuf message google.protobuf.EnumValueDescriptorProto + */ +export interface EnumValueDescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; + /** + * @generated from protobuf field: optional int32 number = 2 + */ + number?: number; + /** + * @generated from protobuf field: optional google.protobuf.EnumValueOptions options = 3 + */ + options?: EnumValueOptions; +} +/** + * Describes a service. + * + * @generated from protobuf message google.protobuf.ServiceDescriptorProto + */ +export interface ServiceDescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; + /** + * @generated from protobuf field: repeated google.protobuf.MethodDescriptorProto method = 2 + */ + method: MethodDescriptorProto[]; + /** + * @generated from protobuf field: optional google.protobuf.ServiceOptions options = 3 + */ + options?: ServiceOptions; +} +/** + * Describes a method of a service. + * + * @generated from protobuf message google.protobuf.MethodDescriptorProto + */ +export interface MethodDescriptorProto { + /** + * @generated from protobuf field: optional string name = 1 + */ + name?: string; + /** + * Input and output type names. These are resolved in the same way as + * FieldDescriptorProto.type_name, but must refer to a message type. + * + * @generated from protobuf field: optional string input_type = 2 + */ + inputType?: string; + /** + * @generated from protobuf field: optional string output_type = 3 + */ + outputType?: string; + /** + * @generated from protobuf field: optional google.protobuf.MethodOptions options = 4 + */ + options?: MethodOptions; + /** + * Identifies if client streams multiple client messages + * + * @generated from protobuf field: optional bool client_streaming = 5 [default = false] + */ + clientStreaming?: boolean; + /** + * Identifies if server streams multiple server messages + * + * @generated from protobuf field: optional bool server_streaming = 6 [default = false] + */ + serverStreaming?: boolean; +} +// =================================================================== +// Options + +// Each of the definitions above may have "options" attached. These are +// just annotations which may cause code to be generated slightly differently +// or may contain hints for code that manipulates protocol messages. +// +// Clients may define custom options as extensions of the *Options messages. +// These extensions may not yet be known at parsing time, so the parser cannot +// store the values in them. Instead it stores them in a field in the *Options +// message called uninterpreted_option. This field must have the same name +// across all *Options messages. We then use this field to populate the +// extensions when we build a descriptor, at which point all protos have been +// parsed and so all extensions are known. +// +// Extension numbers for custom options may be chosen as follows: +// * For options which will only be used within a single application or +// organization, or for experimental options, use field numbers 50000 +// through 99999. It is up to you to ensure that you do not use the +// same number for multiple options. +// * For options which will be published and used publicly by multiple +// independent entities, e-mail protobuf-global-extension-registry@google.com +// to reserve extension numbers. Simply provide your project name (e.g. +// Objective-C plugin) and your project website (if available) -- there's no +// need to explain how you intend to use them. Usually you only need one +// extension number. You can declare multiple options with only one extension +// number by putting them in a sub-message. See the Custom Options section of +// the docs for examples: +// https://developers.google.com/protocol-buffers/docs/proto#options +// If this turns out to be popular, a web service will be set up +// to automatically assign option numbers. + +/** + * @generated from protobuf message google.protobuf.FileOptions + */ +export interface FileOptions { + /** + * Sets the Java package where classes generated from this .proto will be + * placed. By default, the proto package is used, but this is often + * inappropriate because proto packages do not normally start with backwards + * domain names. + * + * @generated from protobuf field: optional string java_package = 1 + */ + javaPackage?: string; + /** + * Controls the name of the wrapper Java class generated for the .proto file. + * That class will always contain the .proto file's getDescriptor() method as + * well as any top-level extensions defined in the .proto file. + * If java_multiple_files is disabled, then all the other classes from the + * .proto file will be nested inside the single wrapper outer class. + * + * @generated from protobuf field: optional string java_outer_classname = 8 + */ + javaOuterClassname?: string; + /** + * If enabled, then the Java code generator will generate a separate .java + * file for each top-level message, enum, and service defined in the .proto + * file. Thus, these types will *not* be nested inside the wrapper class + * named by java_outer_classname. However, the wrapper class will still be + * generated to contain the file's getDescriptor() method as well as any + * top-level extensions defined in the file. + * + * @generated from protobuf field: optional bool java_multiple_files = 10 [default = false] + */ + javaMultipleFiles?: boolean; + /** + * This option does nothing. + * + * @deprecated + * @generated from protobuf field: optional bool java_generate_equals_and_hash = 20 [deprecated = true] + */ + javaGenerateEqualsAndHash?: boolean; + /** + * A proto2 file can set this to true to opt in to UTF-8 checking for Java, + * which will throw an exception if invalid UTF-8 is parsed from the wire or + * assigned to a string field. + * + * TODO: clarify exactly what kinds of field types this option + * applies to, and update these docs accordingly. + * + * Proto3 files already perform these checks. Setting the option explicitly to + * false has no effect: it cannot be used to opt proto3 files out of UTF-8 + * checks. + * + * @generated from protobuf field: optional bool java_string_check_utf8 = 27 [default = false] + */ + javaStringCheckUtf8?: boolean; + /** + * @generated from protobuf field: optional google.protobuf.FileOptions.OptimizeMode optimize_for = 9 [default = SPEED] + */ + optimizeFor?: FileOptions_OptimizeMode; + /** + * Sets the Go package where structs generated from this .proto will be + * placed. If omitted, the Go package will be derived from the following: + * - The basename of the package import path, if provided. + * - Otherwise, the package statement in the .proto file, if present. + * - Otherwise, the basename of the .proto file, without extension. + * + * @generated from protobuf field: optional string go_package = 11 + */ + goPackage?: string; + /** + * Should generic services be generated in each language? "Generic" services + * are not specific to any particular RPC system. They are generated by the + * main code generators in each language (without additional plugins). + * Generic services were the only kind of service generation supported by + * early versions of google.protobuf. + * + * Generic services are now considered deprecated in favor of using plugins + * that generate code specific to your particular RPC system. Therefore, + * these default to false. Old code which depends on generic services should + * explicitly set them to true. + * + * @generated from protobuf field: optional bool cc_generic_services = 16 [default = false] + */ + ccGenericServices?: boolean; + /** + * @generated from protobuf field: optional bool java_generic_services = 17 [default = false] + */ + javaGenericServices?: boolean; + /** + * @generated from protobuf field: optional bool py_generic_services = 18 [default = false] + */ + pyGenericServices?: boolean; + /** + * Is this file deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for everything in the file, or it will be completely ignored; in the very + * least, this is a formalization for deprecating files. + * + * @generated from protobuf field: optional bool deprecated = 23 [default = false] + */ + deprecated?: boolean; + /** + * Enables the use of arenas for the proto messages in this file. This applies + * only to generated classes for C++. + * + * @generated from protobuf field: optional bool cc_enable_arenas = 31 [default = true] + */ + ccEnableArenas?: boolean; + /** + * Sets the objective c class prefix which is prepended to all objective c + * generated classes from this .proto. There is no default. + * + * @generated from protobuf field: optional string objc_class_prefix = 36 + */ + objcClassPrefix?: string; + /** + * Namespace for generated classes; defaults to the package. + * + * @generated from protobuf field: optional string csharp_namespace = 37 + */ + csharpNamespace?: string; + /** + * By default Swift generators will take the proto package and CamelCase it + * replacing '.' with underscore and use that to prefix the types/symbols + * defined. When this options is provided, they will use this value instead + * to prefix the types/symbols defined. + * + * @generated from protobuf field: optional string swift_prefix = 39 + */ + swiftPrefix?: string; + /** + * Sets the php class prefix which is prepended to all php generated classes + * from this .proto. Default is empty. + * + * @generated from protobuf field: optional string php_class_prefix = 40 + */ + phpClassPrefix?: string; + /** + * Use this option to change the namespace of php generated classes. Default + * is empty. When this option is empty, the package name will be used for + * determining the namespace. + * + * @generated from protobuf field: optional string php_namespace = 41 + */ + phpNamespace?: string; + /** + * Use this option to change the namespace of php generated metadata classes. + * Default is empty. When this option is empty, the proto file name will be + * used for determining the namespace. + * + * @generated from protobuf field: optional string php_metadata_namespace = 44 + */ + phpMetadataNamespace?: string; + /** + * Use this option to change the package of ruby generated classes. Default + * is empty. When this option is not set, the package name will be used for + * determining the ruby package. + * + * @generated from protobuf field: optional string ruby_package = 45 + */ + rubyPackage?: string; + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 50 + */ + features?: FeatureSet; + /** + * The parser stores options it doesn't recognize here. + * See the documentation for the "Options" section above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * Generated classes can be optimized for speed or code size. + * + * @generated from protobuf enum google.protobuf.FileOptions.OptimizeMode + */ +export enum FileOptions_OptimizeMode { + /** + * @generated synthetic value - protobuf-ts requires all enums to have a 0 value + */ + UNSPECIFIED$ = 0, + /** + * Generate complete code for parsing, serialization, + * + * @generated from protobuf enum value: SPEED = 1; + */ + SPEED = 1, + /** + * etc. + * + * Use ReflectionOps to implement these methods. + * + * @generated from protobuf enum value: CODE_SIZE = 2; + */ + CODE_SIZE = 2, + /** + * Generate code using MessageLite and the lite runtime. + * + * @generated from protobuf enum value: LITE_RUNTIME = 3; + */ + LITE_RUNTIME = 3 +} +/** + * @generated from protobuf message google.protobuf.MessageOptions + */ +export interface MessageOptions { + /** + * Set true to use the old proto1 MessageSet wire format for extensions. + * This is provided for backwards-compatibility with the MessageSet wire + * format. You should not use this for any other reason: It's less + * efficient, has fewer features, and is more complicated. + * + * The message must be defined exactly as follows: + * message Foo { + * option message_set_wire_format = true; + * extensions 4 to max; + * } + * Note that the message cannot have any defined fields; MessageSets only + * have extensions. + * + * All extensions of your type must be singular messages; e.g. they cannot + * be int32s, enums, or repeated messages. + * + * Because this is an option, the above two restrictions are not enforced by + * the protocol compiler. + * + * @generated from protobuf field: optional bool message_set_wire_format = 1 [default = false] + */ + messageSetWireFormat?: boolean; + /** + * Disables the generation of the standard "descriptor()" accessor, which can + * conflict with a field of the same name. This is meant to make migration + * from proto1 easier; new code should avoid fields named "descriptor". + * + * @generated from protobuf field: optional bool no_standard_descriptor_accessor = 2 [default = false] + */ + noStandardDescriptorAccessor?: boolean; + /** + * Is this message deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the message, or it will be completely ignored; in the very least, + * this is a formalization for deprecating messages. + * + * @generated from protobuf field: optional bool deprecated = 3 [default = false] + */ + deprecated?: boolean; + /** + * Whether the message is an automatically generated map entry type for the + * maps field. + * + * For maps fields: + * map map_field = 1; + * The parsed descriptor looks like: + * message MapFieldEntry { + * option map_entry = true; + * optional KeyType key = 1; + * optional ValueType value = 2; + * } + * repeated MapFieldEntry map_field = 1; + * + * Implementations may choose not to generate the map_entry=true message, but + * use a native map in the target language to hold the keys and values. + * The reflection APIs in such implementations still need to work as + * if the field is a repeated message field. + * + * NOTE: Do not set the option in .proto files. Always use the maps syntax + * instead. The option should only be implicitly set by the proto compiler + * parser. + * + * @generated from protobuf field: optional bool map_entry = 7 + */ + mapEntry?: boolean; + /** + * Enable the legacy handling of JSON field name conflicts. This lowercases + * and strips underscored from the fields before comparison in proto3 only. + * The new behavior takes `json_name` into account and applies to proto2 as + * well. + * + * This should only be used as a temporary measure against broken builds due + * to the change in behavior for JSON field name conflicts. + * + * TODO This is legacy behavior we plan to remove once downstream + * teams have had time to migrate. + * + * @deprecated + * @generated from protobuf field: optional bool deprecated_legacy_json_field_conflicts = 11 [deprecated = true] + */ + deprecatedLegacyJsonFieldConflicts?: boolean; + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 12 + */ + features?: FeatureSet; + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * @generated from protobuf message google.protobuf.FieldOptions + */ +export interface FieldOptions { + /** + * NOTE: ctype is deprecated. Use `features.(pb.cpp).string_type` instead. + * The ctype option instructs the C++ code generator to use a different + * representation of the field than it normally would. See the specific + * options below. This option is only implemented to support use of + * [ctype=CORD] and [ctype=STRING] (the default) on non-repeated fields of + * type "bytes" in the open source release. + * TODO: make ctype actually deprecated. + * + * @generated from protobuf field: optional google.protobuf.FieldOptions.CType ctype = 1 [default = STRING] + */ + ctype?: FieldOptions_CType; + /** + * The packed option can be enabled for repeated primitive fields to enable + * a more efficient representation on the wire. Rather than repeatedly + * writing the tag and type for each element, the entire array is encoded as + * a single length-delimited blob. In proto3, only explicit setting it to + * false will avoid using packed encoding. This option is prohibited in + * Editions, but the `repeated_field_encoding` feature can be used to control + * the behavior. + * + * @generated from protobuf field: optional bool packed = 2 + */ + packed?: boolean; + /** + * The jstype option determines the JavaScript type used for values of the + * field. The option is permitted only for 64 bit integral and fixed types + * (int64, uint64, sint64, fixed64, sfixed64). A field with jstype JS_STRING + * is represented as JavaScript string, which avoids loss of precision that + * can happen when a large value is converted to a floating point JavaScript. + * Specifying JS_NUMBER for the jstype causes the generated JavaScript code to + * use the JavaScript "number" type. The behavior of the default option + * JS_NORMAL is implementation dependent. + * + * This option is an enum to permit additional types to be added, e.g. + * goog.math.Integer. + * + * @generated from protobuf field: optional google.protobuf.FieldOptions.JSType jstype = 6 [default = JS_NORMAL] + */ + jstype?: FieldOptions_JSType; + /** + * Should this field be parsed lazily? Lazy applies only to message-type + * fields. It means that when the outer message is initially parsed, the + * inner message's contents will not be parsed but instead stored in encoded + * form. The inner message will actually be parsed when it is first accessed. + * + * This is only a hint. Implementations are free to choose whether to use + * eager or lazy parsing regardless of the value of this option. However, + * setting this option true suggests that the protocol author believes that + * using lazy parsing on this field is worth the additional bookkeeping + * overhead typically needed to implement it. + * + * This option does not affect the public interface of any generated code; + * all method signatures remain the same. Furthermore, thread-safety of the + * interface is not affected by this option; const methods remain safe to + * call from multiple threads concurrently, while non-const methods continue + * to require exclusive access. + * + * Note that lazy message fields are still eagerly verified to check + * ill-formed wireformat or missing required fields. Calling IsInitialized() + * on the outer message would fail if the inner message has missing required + * fields. Failed verification would result in parsing failure (except when + * uninitialized messages are acceptable). + * + * @generated from protobuf field: optional bool lazy = 5 [default = false] + */ + lazy?: boolean; + /** + * unverified_lazy does no correctness checks on the byte stream. This should + * only be used where lazy with verification is prohibitive for performance + * reasons. + * + * @generated from protobuf field: optional bool unverified_lazy = 15 [default = false] + */ + unverifiedLazy?: boolean; + /** + * Is this field deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for accessors, or it will be completely ignored; in the very least, this + * is a formalization for deprecating fields. + * + * @generated from protobuf field: optional bool deprecated = 3 [default = false] + */ + deprecated?: boolean; + /** + * For Google-internal migration only. Do not use. + * + * @generated from protobuf field: optional bool weak = 10 [default = false] + */ + weak?: boolean; + /** + * Indicate that the field value should not be printed out when using debug + * formats, e.g. when the field contains sensitive credentials. + * + * @generated from protobuf field: optional bool debug_redact = 16 [default = false] + */ + debugRedact?: boolean; + /** + * @generated from protobuf field: optional google.protobuf.FieldOptions.OptionRetention retention = 17 + */ + retention?: FieldOptions_OptionRetention; + /** + * @generated from protobuf field: repeated google.protobuf.FieldOptions.OptionTargetType targets = 19 + */ + targets: FieldOptions_OptionTargetType[]; + /** + * @generated from protobuf field: repeated google.protobuf.FieldOptions.EditionDefault edition_defaults = 20 + */ + editionDefaults: FieldOptions_EditionDefault[]; + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 21 + */ + features?: FeatureSet; + /** + * @generated from protobuf field: optional google.protobuf.FieldOptions.FeatureSupport feature_support = 22 + */ + featureSupport?: FieldOptions_FeatureSupport; + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * @generated from protobuf message google.protobuf.FieldOptions.EditionDefault + */ +export interface FieldOptions_EditionDefault { + /** + * @generated from protobuf field: optional google.protobuf.Edition edition = 3 + */ + edition?: Edition; + /** + * @generated from protobuf field: optional string value = 2 + */ + value?: string; // Textproto value. +} +/** + * Information about the support window of a feature. + * + * @generated from protobuf message google.protobuf.FieldOptions.FeatureSupport + */ +export interface FieldOptions_FeatureSupport { + /** + * The edition that this feature was first available in. In editions + * earlier than this one, the default assigned to EDITION_LEGACY will be + * used, and proto files will not be able to override it. + * + * @generated from protobuf field: optional google.protobuf.Edition edition_introduced = 1 + */ + editionIntroduced?: Edition; + /** + * The edition this feature becomes deprecated in. Using this after this + * edition may trigger warnings. + * + * @generated from protobuf field: optional google.protobuf.Edition edition_deprecated = 2 + */ + editionDeprecated?: Edition; + /** + * The deprecation warning text if this feature is used after the edition it + * was marked deprecated in. + * + * @generated from protobuf field: optional string deprecation_warning = 3 + */ + deprecationWarning?: string; + /** + * The edition this feature is no longer available in. In editions after + * this one, the last default assigned will be used, and proto files will + * not be able to override it. + * + * @generated from protobuf field: optional google.protobuf.Edition edition_removed = 4 + */ + editionRemoved?: Edition; +} +/** + * @generated from protobuf enum google.protobuf.FieldOptions.CType + */ +export enum FieldOptions_CType { + /** + * Default mode. + * + * @generated from protobuf enum value: STRING = 0; + */ + STRING = 0, + /** + * The option [ctype=CORD] may be applied to a non-repeated field of type + * "bytes". It indicates that in C++, the data should be stored in a Cord + * instead of a string. For very large strings, this may reduce memory + * fragmentation. It may also allow better performance when parsing from a + * Cord, or when parsing with aliasing enabled, as the parsed Cord may then + * alias the original buffer. + * + * @generated from protobuf enum value: CORD = 1; + */ + CORD = 1, + /** + * @generated from protobuf enum value: STRING_PIECE = 2; + */ + STRING_PIECE = 2 +} +/** + * @generated from protobuf enum google.protobuf.FieldOptions.JSType + */ +export enum FieldOptions_JSType { + /** + * Use the default type. + * + * @generated from protobuf enum value: JS_NORMAL = 0; + */ + JS_NORMAL = 0, + /** + * Use JavaScript strings. + * + * @generated from protobuf enum value: JS_STRING = 1; + */ + JS_STRING = 1, + /** + * Use JavaScript numbers. + * + * @generated from protobuf enum value: JS_NUMBER = 2; + */ + JS_NUMBER = 2 +} +/** + * If set to RETENTION_SOURCE, the option will be omitted from the binary. + * + * @generated from protobuf enum google.protobuf.FieldOptions.OptionRetention + */ +export enum FieldOptions_OptionRetention { + /** + * @generated from protobuf enum value: RETENTION_UNKNOWN = 0; + */ + RETENTION_UNKNOWN = 0, + /** + * @generated from protobuf enum value: RETENTION_RUNTIME = 1; + */ + RETENTION_RUNTIME = 1, + /** + * @generated from protobuf enum value: RETENTION_SOURCE = 2; + */ + RETENTION_SOURCE = 2 +} +/** + * This indicates the types of entities that the field may apply to when used + * as an option. If it is unset, then the field may be freely used as an + * option on any kind of entity. + * + * @generated from protobuf enum google.protobuf.FieldOptions.OptionTargetType + */ +export enum FieldOptions_OptionTargetType { + /** + * @generated from protobuf enum value: TARGET_TYPE_UNKNOWN = 0; + */ + TARGET_TYPE_UNKNOWN = 0, + /** + * @generated from protobuf enum value: TARGET_TYPE_FILE = 1; + */ + TARGET_TYPE_FILE = 1, + /** + * @generated from protobuf enum value: TARGET_TYPE_EXTENSION_RANGE = 2; + */ + TARGET_TYPE_EXTENSION_RANGE = 2, + /** + * @generated from protobuf enum value: TARGET_TYPE_MESSAGE = 3; + */ + TARGET_TYPE_MESSAGE = 3, + /** + * @generated from protobuf enum value: TARGET_TYPE_FIELD = 4; + */ + TARGET_TYPE_FIELD = 4, + /** + * @generated from protobuf enum value: TARGET_TYPE_ONEOF = 5; + */ + TARGET_TYPE_ONEOF = 5, + /** + * @generated from protobuf enum value: TARGET_TYPE_ENUM = 6; + */ + TARGET_TYPE_ENUM = 6, + /** + * @generated from protobuf enum value: TARGET_TYPE_ENUM_ENTRY = 7; + */ + TARGET_TYPE_ENUM_ENTRY = 7, + /** + * @generated from protobuf enum value: TARGET_TYPE_SERVICE = 8; + */ + TARGET_TYPE_SERVICE = 8, + /** + * @generated from protobuf enum value: TARGET_TYPE_METHOD = 9; + */ + TARGET_TYPE_METHOD = 9 +} +/** + * @generated from protobuf message google.protobuf.OneofOptions + */ +export interface OneofOptions { + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 1 + */ + features?: FeatureSet; + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * @generated from protobuf message google.protobuf.EnumOptions + */ +export interface EnumOptions { + /** + * Set this option to true to allow mapping different tag names to the same + * value. + * + * @generated from protobuf field: optional bool allow_alias = 2 + */ + allowAlias?: boolean; + /** + * Is this enum deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the enum, or it will be completely ignored; in the very least, this + * is a formalization for deprecating enums. + * + * @generated from protobuf field: optional bool deprecated = 3 [default = false] + */ + deprecated?: boolean; + /** + * Enable the legacy handling of JSON field name conflicts. This lowercases + * and strips underscored from the fields before comparison in proto3 only. + * The new behavior takes `json_name` into account and applies to proto2 as + * well. + * TODO Remove this legacy behavior once downstream teams have + * had time to migrate. + * + * @deprecated + * @generated from protobuf field: optional bool deprecated_legacy_json_field_conflicts = 6 [deprecated = true] + */ + deprecatedLegacyJsonFieldConflicts?: boolean; + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 7 + */ + features?: FeatureSet; + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * @generated from protobuf message google.protobuf.EnumValueOptions + */ +export interface EnumValueOptions { + /** + * Is this enum value deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the enum value, or it will be completely ignored; in the very least, + * this is a formalization for deprecating enum values. + * + * @generated from protobuf field: optional bool deprecated = 1 [default = false] + */ + deprecated?: boolean; + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 2 + */ + features?: FeatureSet; + /** + * Indicate that fields annotated with this enum value should not be printed + * out when using debug formats, e.g. when the field contains sensitive + * credentials. + * + * @generated from protobuf field: optional bool debug_redact = 3 [default = false] + */ + debugRedact?: boolean; + /** + * Information about the support window of a feature value. + * + * @generated from protobuf field: optional google.protobuf.FieldOptions.FeatureSupport feature_support = 4 + */ + featureSupport?: FieldOptions_FeatureSupport; + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * @generated from protobuf message google.protobuf.ServiceOptions + */ +export interface ServiceOptions { + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 34 + */ + features?: FeatureSet; + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + /** + * Is this service deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the service, or it will be completely ignored; in the very least, + * this is a formalization for deprecating services. + * + * @generated from protobuf field: optional bool deprecated = 33 [default = false] + */ + deprecated?: boolean; + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * @generated from protobuf message google.protobuf.MethodOptions + */ +export interface MethodOptions { + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + /** + * Is this method deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the method, or it will be completely ignored; in the very least, + * this is a formalization for deprecating methods. + * + * @generated from protobuf field: optional bool deprecated = 33 [default = false] + */ + deprecated?: boolean; + /** + * @generated from protobuf field: optional google.protobuf.MethodOptions.IdempotencyLevel idempotency_level = 34 [default = IDEMPOTENCY_UNKNOWN] + */ + idempotencyLevel?: MethodOptions_IdempotencyLevel; + /** + * Any features defined in the specific edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet features = 35 + */ + features?: FeatureSet; + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999 + */ + uninterpretedOption: UninterpretedOption[]; +} +/** + * Is this method side-effect-free (or safe in HTTP parlance), or idempotent, + * or neither? HTTP based RPC implementation may choose GET verb for safe + * methods, and PUT verb for idempotent methods instead of the default POST. + * + * @generated from protobuf enum google.protobuf.MethodOptions.IdempotencyLevel + */ +export enum MethodOptions_IdempotencyLevel { + /** + * @generated from protobuf enum value: IDEMPOTENCY_UNKNOWN = 0; + */ + IDEMPOTENCY_UNKNOWN = 0, + /** + * implies idempotent + * + * @generated from protobuf enum value: NO_SIDE_EFFECTS = 1; + */ + NO_SIDE_EFFECTS = 1, + /** + * idempotent, but may have side effects + * + * @generated from protobuf enum value: IDEMPOTENT = 2; + */ + IDEMPOTENT = 2 +} +/** + * A message representing a option the parser does not recognize. This only + * appears in options protos created by the compiler::Parser class. + * DescriptorPool resolves these when building Descriptor objects. Therefore, + * options protos in descriptor objects (e.g. returned by Descriptor::options(), + * or produced by Descriptor::CopyTo()) will never have UninterpretedOptions + * in them. + * + * @generated from protobuf message google.protobuf.UninterpretedOption + */ +export interface UninterpretedOption { + /** + * @generated from protobuf field: repeated google.protobuf.UninterpretedOption.NamePart name = 2 + */ + name: UninterpretedOption_NamePart[]; + /** + * The value of the uninterpreted option, in whatever type the tokenizer + * identified it as during parsing. Exactly one of these should be set. + * + * @generated from protobuf field: optional string identifier_value = 3 + */ + identifierValue?: string; + /** + * @generated from protobuf field: optional uint64 positive_int_value = 4 + */ + positiveIntValue?: bigint; + /** + * @generated from protobuf field: optional int64 negative_int_value = 5 + */ + negativeIntValue?: bigint; + /** + * @generated from protobuf field: optional double double_value = 6 + */ + doubleValue?: number; + /** + * @generated from protobuf field: optional bytes string_value = 7 + */ + stringValue?: Uint8Array; + /** + * @generated from protobuf field: optional string aggregate_value = 8 + */ + aggregateValue?: string; +} +/** + * The name of the uninterpreted option. Each string represents a segment in + * a dot-separated name. is_extension is true iff a segment represents an + * extension (denoted with parentheses in options specs in .proto files). + * E.g.,{ ["foo", false], ["bar.baz", true], ["moo", false] } represents + * "foo.(bar.baz).moo". + * + * @generated from protobuf message google.protobuf.UninterpretedOption.NamePart + */ +export interface UninterpretedOption_NamePart { + /** + * @generated from protobuf field: required string name_part = 1 + */ + namePart: string; + /** + * @generated from protobuf field: required bool is_extension = 2 + */ + isExtension: boolean; +} +// =================================================================== +// Features + +/** + * TODO Enums in C++ gencode (and potentially other languages) are + * not well scoped. This means that each of the feature enums below can clash + * with each other. The short names we've chosen maximize call-site + * readability, but leave us very open to this scenario. A future feature will + * be designed and implemented to handle this, hopefully before we ever hit a + * conflict here. + * + * @generated from protobuf message google.protobuf.FeatureSet + */ +export interface FeatureSet { + /** + * @generated from protobuf field: optional google.protobuf.FeatureSet.FieldPresence field_presence = 1 + */ + fieldPresence?: FeatureSet_FieldPresence; + /** + * @generated from protobuf field: optional google.protobuf.FeatureSet.EnumType enum_type = 2 + */ + enumType?: FeatureSet_EnumType; + /** + * @generated from protobuf field: optional google.protobuf.FeatureSet.RepeatedFieldEncoding repeated_field_encoding = 3 + */ + repeatedFieldEncoding?: FeatureSet_RepeatedFieldEncoding; + /** + * @generated from protobuf field: optional google.protobuf.FeatureSet.Utf8Validation utf8_validation = 4 + */ + utf8Validation?: FeatureSet_Utf8Validation; + /** + * @generated from protobuf field: optional google.protobuf.FeatureSet.MessageEncoding message_encoding = 5 + */ + messageEncoding?: FeatureSet_MessageEncoding; + /** + * @generated from protobuf field: optional google.protobuf.FeatureSet.JsonFormat json_format = 6 + */ + jsonFormat?: FeatureSet_JsonFormat; +} +/** + * @generated from protobuf enum google.protobuf.FeatureSet.FieldPresence + */ +export enum FeatureSet_FieldPresence { + /** + * @generated from protobuf enum value: FIELD_PRESENCE_UNKNOWN = 0; + */ + FIELD_PRESENCE_UNKNOWN = 0, + /** + * @generated from protobuf enum value: EXPLICIT = 1; + */ + EXPLICIT = 1, + /** + * @generated from protobuf enum value: IMPLICIT = 2; + */ + IMPLICIT = 2, + /** + * @generated from protobuf enum value: LEGACY_REQUIRED = 3; + */ + LEGACY_REQUIRED = 3 +} +/** + * @generated from protobuf enum google.protobuf.FeatureSet.EnumType + */ +export enum FeatureSet_EnumType { + /** + * @generated from protobuf enum value: ENUM_TYPE_UNKNOWN = 0; + */ + ENUM_TYPE_UNKNOWN = 0, + /** + * @generated from protobuf enum value: OPEN = 1; + */ + OPEN = 1, + /** + * @generated from protobuf enum value: CLOSED = 2; + */ + CLOSED = 2 +} +/** + * @generated from protobuf enum google.protobuf.FeatureSet.RepeatedFieldEncoding + */ +export enum FeatureSet_RepeatedFieldEncoding { + /** + * @generated from protobuf enum value: REPEATED_FIELD_ENCODING_UNKNOWN = 0; + */ + REPEATED_FIELD_ENCODING_UNKNOWN = 0, + /** + * @generated from protobuf enum value: PACKED = 1; + */ + PACKED = 1, + /** + * @generated from protobuf enum value: EXPANDED = 2; + */ + EXPANDED = 2 +} +/** + * @generated from protobuf enum google.protobuf.FeatureSet.Utf8Validation + */ +export enum FeatureSet_Utf8Validation { + /** + * @generated from protobuf enum value: UTF8_VALIDATION_UNKNOWN = 0; + */ + UTF8_VALIDATION_UNKNOWN = 0, + /** + * @generated from protobuf enum value: VERIFY = 2; + */ + VERIFY = 2, + /** + * @generated from protobuf enum value: NONE = 3; + */ + NONE = 3 +} +/** + * @generated from protobuf enum google.protobuf.FeatureSet.MessageEncoding + */ +export enum FeatureSet_MessageEncoding { + /** + * @generated from protobuf enum value: MESSAGE_ENCODING_UNKNOWN = 0; + */ + MESSAGE_ENCODING_UNKNOWN = 0, + /** + * @generated from protobuf enum value: LENGTH_PREFIXED = 1; + */ + LENGTH_PREFIXED = 1, + /** + * @generated from protobuf enum value: DELIMITED = 2; + */ + DELIMITED = 2 +} +/** + * @generated from protobuf enum google.protobuf.FeatureSet.JsonFormat + */ +export enum FeatureSet_JsonFormat { + /** + * @generated from protobuf enum value: JSON_FORMAT_UNKNOWN = 0; + */ + JSON_FORMAT_UNKNOWN = 0, + /** + * @generated from protobuf enum value: ALLOW = 1; + */ + ALLOW = 1, + /** + * @generated from protobuf enum value: LEGACY_BEST_EFFORT = 2; + */ + LEGACY_BEST_EFFORT = 2 +} +/** + * A compiled specification for the defaults of a set of features. These + * messages are generated from FeatureSet extensions and can be used to seed + * feature resolution. The resolution with this object becomes a simple search + * for the closest matching edition, followed by proto merges. + * + * @generated from protobuf message google.protobuf.FeatureSetDefaults + */ +export interface FeatureSetDefaults { + /** + * @generated from protobuf field: repeated google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault defaults = 1 + */ + defaults: FeatureSetDefaults_FeatureSetEditionDefault[]; + /** + * The minimum supported edition (inclusive) when this was constructed. + * Editions before this will not have defaults. + * + * @generated from protobuf field: optional google.protobuf.Edition minimum_edition = 4 + */ + minimumEdition?: Edition; + /** + * The maximum known edition (inclusive) when this was constructed. Editions + * after this will not have reliable defaults. + * + * @generated from protobuf field: optional google.protobuf.Edition maximum_edition = 5 + */ + maximumEdition?: Edition; +} +/** + * A map from every known edition with a unique set of defaults to its + * defaults. Not all editions may be contained here. For a given edition, + * the defaults at the closest matching edition ordered at or before it should + * be used. This field must be in strict ascending order by edition. + * + * @generated from protobuf message google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault + */ +export interface FeatureSetDefaults_FeatureSetEditionDefault { + /** + * @generated from protobuf field: optional google.protobuf.Edition edition = 3 + */ + edition?: Edition; + /** + * Defaults of features that can be overridden in this edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet overridable_features = 4 + */ + overridableFeatures?: FeatureSet; + /** + * Defaults of features that can't be overridden in this edition. + * + * @generated from protobuf field: optional google.protobuf.FeatureSet fixed_features = 5 + */ + fixedFeatures?: FeatureSet; +} +// =================================================================== +// Optional source code info + +/** + * Encapsulates information about the original source file from which a + * FileDescriptorProto was generated. + * + * @generated from protobuf message google.protobuf.SourceCodeInfo + */ +export interface SourceCodeInfo { + /** + * A Location identifies a piece of source code in a .proto file which + * corresponds to a particular definition. This information is intended + * to be useful to IDEs, code indexers, documentation generators, and similar + * tools. + * + * For example, say we have a file like: + * message Foo { + * optional string foo = 1; + * } + * Let's look at just the field definition: + * optional string foo = 1; + * ^ ^^ ^^ ^ ^^^ + * a bc de f ghi + * We have the following locations: + * span path represents + * [a,i) [ 4, 0, 2, 0 ] The whole field definition. + * [a,b) [ 4, 0, 2, 0, 4 ] The label (optional). + * [c,d) [ 4, 0, 2, 0, 5 ] The type (string). + * [e,f) [ 4, 0, 2, 0, 1 ] The name (foo). + * [g,h) [ 4, 0, 2, 0, 3 ] The number (1). + * + * Notes: + * - A location may refer to a repeated field itself (i.e. not to any + * particular index within it). This is used whenever a set of elements are + * logically enclosed in a single code segment. For example, an entire + * extend block (possibly containing multiple extension definitions) will + * have an outer location whose path refers to the "extensions" repeated + * field without an index. + * - Multiple locations may have the same path. This happens when a single + * logical declaration is spread out across multiple places. The most + * obvious example is the "extend" block again -- there may be multiple + * extend blocks in the same scope, each of which will have the same path. + * - A location's span is not always a subset of its parent's span. For + * example, the "extendee" of an extension declaration appears at the + * beginning of the "extend" block and is shared by all extensions within + * the block. + * - Just because a location's span is a subset of some other location's span + * does not mean that it is a descendant. For example, a "group" defines + * both a type and a field in a single declaration. Thus, the locations + * corresponding to the type and field and their components will overlap. + * - Code which tries to interpret locations should probably be designed to + * ignore those that it doesn't understand, as more types of locations could + * be recorded in the future. + * + * @generated from protobuf field: repeated google.protobuf.SourceCodeInfo.Location location = 1 + */ + location: SourceCodeInfo_Location[]; +} +/** + * @generated from protobuf message google.protobuf.SourceCodeInfo.Location + */ +export interface SourceCodeInfo_Location { + /** + * Identifies which part of the FileDescriptorProto was defined at this + * location. + * + * Each element is a field number or an index. They form a path from + * the root FileDescriptorProto to the place where the definition appears. + * For example, this path: + * [ 4, 3, 2, 7, 1 ] + * refers to: + * file.message_type(3) // 4, 3 + * .field(7) // 2, 7 + * .name() // 1 + * This is because FileDescriptorProto.message_type has field number 4: + * repeated DescriptorProto message_type = 4; + * and DescriptorProto.field has field number 2: + * repeated FieldDescriptorProto field = 2; + * and FieldDescriptorProto.name has field number 1: + * optional string name = 1; + * + * Thus, the above path gives the location of a field name. If we removed + * the last element: + * [ 4, 3, 2, 7 ] + * this path refers to the whole field declaration (from the beginning + * of the label to the terminating semicolon). + * + * @generated from protobuf field: repeated int32 path = 1 [packed = true] + */ + path: number[]; + /** + * Always has exactly three or four elements: start line, start column, + * end line (optional, otherwise assumed same as start line), end column. + * These are packed into a single field for efficiency. Note that line + * and column numbers are zero-based -- typically you will want to add + * 1 to each before displaying to a user. + * + * @generated from protobuf field: repeated int32 span = 2 [packed = true] + */ + span: number[]; + /** + * If this SourceCodeInfo represents a complete declaration, these are any + * comments appearing before and after the declaration which appear to be + * attached to the declaration. + * + * A series of line comments appearing on consecutive lines, with no other + * tokens appearing on those lines, will be treated as a single comment. + * + * leading_detached_comments will keep paragraphs of comments that appear + * before (but not connected to) the current element. Each paragraph, + * separated by empty lines, will be one comment element in the repeated + * field. + * + * Only the comment content is provided; comment markers (e.g. //) are + * stripped out. For block comments, leading whitespace and an asterisk + * will be stripped from the beginning of each line other than the first. + * Newlines are included in the output. + * + * Examples: + * + * optional int32 foo = 1; // Comment attached to foo. + * // Comment attached to bar. + * optional int32 bar = 2; + * + * optional string baz = 3; + * // Comment attached to baz. + * // Another line attached to baz. + * + * // Comment attached to moo. + * // + * // Another line attached to moo. + * optional double moo = 4; + * + * // Detached comment for corge. This is not leading or trailing comments + * // to moo or corge because there are blank lines separating it from + * // both. + * + * // Detached comment for corge paragraph 2. + * + * optional string corge = 5; + * /* Block comment attached + * * to corge. Leading asterisks + * * will be removed. *\/ + * /* Block comment attached to + * * grault. *\/ + * optional int32 grault = 6; + * + * // ignored detached comments. + * + * @generated from protobuf field: optional string leading_comments = 3 + */ + leadingComments?: string; + /** + * @generated from protobuf field: optional string trailing_comments = 4 + */ + trailingComments?: string; + /** + * @generated from protobuf field: repeated string leading_detached_comments = 6 + */ + leadingDetachedComments: string[]; +} +/** + * Describes the relationship between generated code and its original source + * file. A GeneratedCodeInfo message is associated with only one generated + * source file, but may contain references to different source .proto files. + * + * @generated from protobuf message google.protobuf.GeneratedCodeInfo + */ +export interface GeneratedCodeInfo { + /** + * An Annotation connects some span of text in generated code to an element + * of its generating .proto file. + * + * @generated from protobuf field: repeated google.protobuf.GeneratedCodeInfo.Annotation annotation = 1 + */ + annotation: GeneratedCodeInfo_Annotation[]; +} +/** + * @generated from protobuf message google.protobuf.GeneratedCodeInfo.Annotation + */ +export interface GeneratedCodeInfo_Annotation { + /** + * Identifies the element in the original source .proto file. This field + * is formatted the same as SourceCodeInfo.Location.path. + * + * @generated from protobuf field: repeated int32 path = 1 [packed = true] + */ + path: number[]; + /** + * Identifies the filesystem path to the original source .proto. + * + * @generated from protobuf field: optional string source_file = 2 + */ + sourceFile?: string; + /** + * Identifies the starting offset in bytes in the generated code + * that relates to the identified object. + * + * @generated from protobuf field: optional int32 begin = 3 + */ + begin?: number; + /** + * Identifies the ending offset in bytes in the generated code that + * relates to the identified object. The end offset should be one past + * the last relevant byte (so the length of the text = end - begin). + * + * @generated from protobuf field: optional int32 end = 4 + */ + end?: number; + /** + * @generated from protobuf field: optional google.protobuf.GeneratedCodeInfo.Annotation.Semantic semantic = 5 + */ + semantic?: GeneratedCodeInfo_Annotation_Semantic; +} +/** + * Represents the identified object's effect on the element in the original + * .proto file. + * + * @generated from protobuf enum google.protobuf.GeneratedCodeInfo.Annotation.Semantic + */ +export enum GeneratedCodeInfo_Annotation_Semantic { + /** + * There is no effect or the effect is indescribable. + * + * @generated from protobuf enum value: NONE = 0; + */ + NONE = 0, + /** + * The element is set or otherwise mutated. + * + * @generated from protobuf enum value: SET = 1; + */ + SET = 1, + /** + * An alias to the element is returned. + * + * @generated from protobuf enum value: ALIAS = 2; + */ + ALIAS = 2 +} +/** + * The full set of known editions. + * + * @generated from protobuf enum google.protobuf.Edition + */ +export enum Edition { + /** + * A placeholder for an unknown edition value. + * + * @generated from protobuf enum value: EDITION_UNKNOWN = 0; + */ + EDITION_UNKNOWN = 0, + /** + * A placeholder edition for specifying default behaviors *before* a feature + * was first introduced. This is effectively an "infinite past". + * + * @generated from protobuf enum value: EDITION_LEGACY = 900; + */ + EDITION_LEGACY = 900, + /** + * Legacy syntax "editions". These pre-date editions, but behave much like + * distinct editions. These can't be used to specify the edition of proto + * files, but feature definitions must supply proto2/proto3 defaults for + * backwards compatibility. + * + * @generated from protobuf enum value: EDITION_PROTO2 = 998; + */ + EDITION_PROTO2 = 998, + /** + * @generated from protobuf enum value: EDITION_PROTO3 = 999; + */ + EDITION_PROTO3 = 999, + /** + * Editions that have been released. The specific values are arbitrary and + * should not be depended on, but they will always be time-ordered for easy + * comparison. + * + * @generated from protobuf enum value: EDITION_2023 = 1000; + */ + EDITION_2023 = 1000, + /** + * @generated from protobuf enum value: EDITION_2024 = 1001; + */ + EDITION_2024 = 1001, + /** + * Placeholder editions for testing feature resolution. These should not be + * used or relied on outside of tests. + * + * @generated from protobuf enum value: EDITION_1_TEST_ONLY = 1; + */ + EDITION_1_TEST_ONLY = 1, + /** + * @generated from protobuf enum value: EDITION_2_TEST_ONLY = 2; + */ + EDITION_2_TEST_ONLY = 2, + /** + * @generated from protobuf enum value: EDITION_99997_TEST_ONLY = 99997; + */ + EDITION_99997_TEST_ONLY = 99997, + /** + * @generated from protobuf enum value: EDITION_99998_TEST_ONLY = 99998; + */ + EDITION_99998_TEST_ONLY = 99998, + /** + * @generated from protobuf enum value: EDITION_99999_TEST_ONLY = 99999; + */ + EDITION_99999_TEST_ONLY = 99999, + /** + * Placeholder for specifying unbounded edition support. This should only + * ever be used by plugins that can expect to never require any changes to + * support a new edition. + * + * @generated from protobuf enum value: EDITION_MAX = 2147483647; + */ + EDITION_MAX = 2147483647 +} +// @generated message type with reflection information, may provide speed optimized methods +class FileDescriptorSet$Type extends MessageType { + constructor() { + super("google.protobuf.FileDescriptorSet", [ + { no: 1, name: "file", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => FileDescriptorProto } + ]); + } + create(value?: PartialMessage): FileDescriptorSet { + const message = globalThis.Object.create((this.messagePrototype!)); + message.file = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FileDescriptorSet): FileDescriptorSet { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated google.protobuf.FileDescriptorProto file */ 1: + message.file.push(FileDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FileDescriptorSet, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated google.protobuf.FileDescriptorProto file = 1; */ + for (let i = 0; i < message.file.length; i++) + FileDescriptorProto.internalBinaryWrite(message.file[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FileDescriptorSet + */ +export const FileDescriptorSet = new FileDescriptorSet$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FileDescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.FileDescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "package", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "dependency", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, + { no: 10, name: "public_dependency", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 5 /*ScalarType.INT32*/ }, + { no: 11, name: "weak_dependency", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 5 /*ScalarType.INT32*/ }, + { no: 4, name: "message_type", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => DescriptorProto }, + { no: 5, name: "enum_type", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EnumDescriptorProto }, + { no: 6, name: "service", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => ServiceDescriptorProto }, + { no: 7, name: "extension", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => FieldDescriptorProto }, + { no: 8, name: "options", kind: "message", T: () => FileOptions }, + { no: 9, name: "source_code_info", kind: "message", T: () => SourceCodeInfo }, + { no: 12, name: "syntax", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 14, name: "edition", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] } + ]); + } + create(value?: PartialMessage): FileDescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + message.dependency = []; + message.publicDependency = []; + message.weakDependency = []; + message.messageType = []; + message.enumType = []; + message.service = []; + message.extension = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FileDescriptorProto): FileDescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* optional string package */ 2: + message.package = reader.string(); + break; + case /* repeated string dependency */ 3: + message.dependency.push(reader.string()); + break; + case /* repeated int32 public_dependency */ 10: + if (wireType === WireType.LengthDelimited) + for (let e = reader.int32() + reader.pos; reader.pos < e;) + message.publicDependency.push(reader.int32()); + else + message.publicDependency.push(reader.int32()); + break; + case /* repeated int32 weak_dependency */ 11: + if (wireType === WireType.LengthDelimited) + for (let e = reader.int32() + reader.pos; reader.pos < e;) + message.weakDependency.push(reader.int32()); + else + message.weakDependency.push(reader.int32()); + break; + case /* repeated google.protobuf.DescriptorProto message_type */ 4: + message.messageType.push(DescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.EnumDescriptorProto enum_type */ 5: + message.enumType.push(EnumDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.ServiceDescriptorProto service */ 6: + message.service.push(ServiceDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.FieldDescriptorProto extension */ 7: + message.extension.push(FieldDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional google.protobuf.FileOptions options */ 8: + message.options = FileOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + case /* optional google.protobuf.SourceCodeInfo source_code_info */ 9: + message.sourceCodeInfo = SourceCodeInfo.internalBinaryRead(reader, reader.uint32(), options, message.sourceCodeInfo); + break; + case /* optional string syntax */ 12: + message.syntax = reader.string(); + break; + case /* optional google.protobuf.Edition edition */ 14: + message.edition = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FileDescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* optional string package = 2; */ + if (message.package !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.package); + /* repeated string dependency = 3; */ + for (let i = 0; i < message.dependency.length; i++) + writer.tag(3, WireType.LengthDelimited).string(message.dependency[i]); + /* repeated google.protobuf.DescriptorProto message_type = 4; */ + for (let i = 0; i < message.messageType.length; i++) + DescriptorProto.internalBinaryWrite(message.messageType[i], writer.tag(4, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.EnumDescriptorProto enum_type = 5; */ + for (let i = 0; i < message.enumType.length; i++) + EnumDescriptorProto.internalBinaryWrite(message.enumType[i], writer.tag(5, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.ServiceDescriptorProto service = 6; */ + for (let i = 0; i < message.service.length; i++) + ServiceDescriptorProto.internalBinaryWrite(message.service[i], writer.tag(6, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.FieldDescriptorProto extension = 7; */ + for (let i = 0; i < message.extension.length; i++) + FieldDescriptorProto.internalBinaryWrite(message.extension[i], writer.tag(7, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.FileOptions options = 8; */ + if (message.options) + FileOptions.internalBinaryWrite(message.options, writer.tag(8, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.SourceCodeInfo source_code_info = 9; */ + if (message.sourceCodeInfo) + SourceCodeInfo.internalBinaryWrite(message.sourceCodeInfo, writer.tag(9, WireType.LengthDelimited).fork(), options).join(); + /* repeated int32 public_dependency = 10; */ + for (let i = 0; i < message.publicDependency.length; i++) + writer.tag(10, WireType.Varint).int32(message.publicDependency[i]); + /* repeated int32 weak_dependency = 11; */ + for (let i = 0; i < message.weakDependency.length; i++) + writer.tag(11, WireType.Varint).int32(message.weakDependency[i]); + /* optional string syntax = 12; */ + if (message.syntax !== undefined) + writer.tag(12, WireType.LengthDelimited).string(message.syntax); + /* optional google.protobuf.Edition edition = 14; */ + if (message.edition !== undefined) + writer.tag(14, WireType.Varint).int32(message.edition); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FileDescriptorProto + */ +export const FileDescriptorProto = new FileDescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class DescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.DescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "field", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => FieldDescriptorProto }, + { no: 6, name: "extension", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => FieldDescriptorProto }, + { no: 3, name: "nested_type", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => DescriptorProto }, + { no: 4, name: "enum_type", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EnumDescriptorProto }, + { no: 5, name: "extension_range", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => DescriptorProto_ExtensionRange }, + { no: 8, name: "oneof_decl", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => OneofDescriptorProto }, + { no: 7, name: "options", kind: "message", T: () => MessageOptions }, + { no: 9, name: "reserved_range", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => DescriptorProto_ReservedRange }, + { no: 10, name: "reserved_name", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): DescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + message.field = []; + message.extension = []; + message.nestedType = []; + message.enumType = []; + message.extensionRange = []; + message.oneofDecl = []; + message.reservedRange = []; + message.reservedName = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DescriptorProto): DescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* repeated google.protobuf.FieldDescriptorProto field */ 2: + message.field.push(FieldDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.FieldDescriptorProto extension */ 6: + message.extension.push(FieldDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.DescriptorProto nested_type */ 3: + message.nestedType.push(DescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.EnumDescriptorProto enum_type */ 4: + message.enumType.push(EnumDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.DescriptorProto.ExtensionRange extension_range */ 5: + message.extensionRange.push(DescriptorProto_ExtensionRange.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.OneofDescriptorProto oneof_decl */ 8: + message.oneofDecl.push(OneofDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional google.protobuf.MessageOptions options */ 7: + message.options = MessageOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + case /* repeated google.protobuf.DescriptorProto.ReservedRange reserved_range */ 9: + message.reservedRange.push(DescriptorProto_ReservedRange.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated string reserved_name */ 10: + message.reservedName.push(reader.string()); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* repeated google.protobuf.FieldDescriptorProto field = 2; */ + for (let i = 0; i < message.field.length; i++) + FieldDescriptorProto.internalBinaryWrite(message.field[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.DescriptorProto nested_type = 3; */ + for (let i = 0; i < message.nestedType.length; i++) + DescriptorProto.internalBinaryWrite(message.nestedType[i], writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.EnumDescriptorProto enum_type = 4; */ + for (let i = 0; i < message.enumType.length; i++) + EnumDescriptorProto.internalBinaryWrite(message.enumType[i], writer.tag(4, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.DescriptorProto.ExtensionRange extension_range = 5; */ + for (let i = 0; i < message.extensionRange.length; i++) + DescriptorProto_ExtensionRange.internalBinaryWrite(message.extensionRange[i], writer.tag(5, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.FieldDescriptorProto extension = 6; */ + for (let i = 0; i < message.extension.length; i++) + FieldDescriptorProto.internalBinaryWrite(message.extension[i], writer.tag(6, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.MessageOptions options = 7; */ + if (message.options) + MessageOptions.internalBinaryWrite(message.options, writer.tag(7, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.OneofDescriptorProto oneof_decl = 8; */ + for (let i = 0; i < message.oneofDecl.length; i++) + OneofDescriptorProto.internalBinaryWrite(message.oneofDecl[i], writer.tag(8, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.DescriptorProto.ReservedRange reserved_range = 9; */ + for (let i = 0; i < message.reservedRange.length; i++) + DescriptorProto_ReservedRange.internalBinaryWrite(message.reservedRange[i], writer.tag(9, WireType.LengthDelimited).fork(), options).join(); + /* repeated string reserved_name = 10; */ + for (let i = 0; i < message.reservedName.length; i++) + writer.tag(10, WireType.LengthDelimited).string(message.reservedName[i]); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.DescriptorProto + */ +export const DescriptorProto = new DescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class DescriptorProto_ExtensionRange$Type extends MessageType { + constructor() { + super("google.protobuf.DescriptorProto.ExtensionRange", [ + { no: 1, name: "start", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 2, name: "end", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 3, name: "options", kind: "message", T: () => ExtensionRangeOptions } + ]); + } + create(value?: PartialMessage): DescriptorProto_ExtensionRange { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DescriptorProto_ExtensionRange): DescriptorProto_ExtensionRange { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional int32 start */ 1: + message.start = reader.int32(); + break; + case /* optional int32 end */ 2: + message.end = reader.int32(); + break; + case /* optional google.protobuf.ExtensionRangeOptions options */ 3: + message.options = ExtensionRangeOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DescriptorProto_ExtensionRange, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional int32 start = 1; */ + if (message.start !== undefined) + writer.tag(1, WireType.Varint).int32(message.start); + /* optional int32 end = 2; */ + if (message.end !== undefined) + writer.tag(2, WireType.Varint).int32(message.end); + /* optional google.protobuf.ExtensionRangeOptions options = 3; */ + if (message.options) + ExtensionRangeOptions.internalBinaryWrite(message.options, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.DescriptorProto.ExtensionRange + */ +export const DescriptorProto_ExtensionRange = new DescriptorProto_ExtensionRange$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class DescriptorProto_ReservedRange$Type extends MessageType { + constructor() { + super("google.protobuf.DescriptorProto.ReservedRange", [ + { no: 1, name: "start", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 2, name: "end", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ } + ]); + } + create(value?: PartialMessage): DescriptorProto_ReservedRange { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DescriptorProto_ReservedRange): DescriptorProto_ReservedRange { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional int32 start */ 1: + message.start = reader.int32(); + break; + case /* optional int32 end */ 2: + message.end = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DescriptorProto_ReservedRange, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional int32 start = 1; */ + if (message.start !== undefined) + writer.tag(1, WireType.Varint).int32(message.start); + /* optional int32 end = 2; */ + if (message.end !== undefined) + writer.tag(2, WireType.Varint).int32(message.end); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.DescriptorProto.ReservedRange + */ +export const DescriptorProto_ReservedRange = new DescriptorProto_ReservedRange$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ExtensionRangeOptions$Type extends MessageType { + constructor() { + super("google.protobuf.ExtensionRangeOptions", [ + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption }, + { no: 2, name: "declaration", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => ExtensionRangeOptions_Declaration }, + { no: 50, name: "features", kind: "message", T: () => FeatureSet }, + { no: 3, name: "verification", kind: "enum", opt: true, T: () => ["google.protobuf.ExtensionRangeOptions.VerificationState", ExtensionRangeOptions_VerificationState] } + ]); + } + create(value?: PartialMessage): ExtensionRangeOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + message.declaration = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ExtensionRangeOptions): ExtensionRangeOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated google.protobuf.ExtensionRangeOptions.Declaration declaration */ 2: + message.declaration.push(ExtensionRangeOptions_Declaration.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional google.protobuf.FeatureSet features */ 50: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* optional google.protobuf.ExtensionRangeOptions.VerificationState verification = 3 [default = UNVERIFIED] */ 3: + message.verification = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: ExtensionRangeOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated google.protobuf.ExtensionRangeOptions.Declaration declaration = 2; */ + for (let i = 0; i < message.declaration.length; i++) + ExtensionRangeOptions_Declaration.internalBinaryWrite(message.declaration[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.ExtensionRangeOptions.VerificationState verification = 3 [default = UNVERIFIED]; */ + if (message.verification !== undefined) + writer.tag(3, WireType.Varint).int32(message.verification); + /* optional google.protobuf.FeatureSet features = 50; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(50, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.ExtensionRangeOptions + */ +export const ExtensionRangeOptions = new ExtensionRangeOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ExtensionRangeOptions_Declaration$Type extends MessageType { + constructor() { + super("google.protobuf.ExtensionRangeOptions.Declaration", [ + { no: 1, name: "number", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 2, name: "full_name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "type", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 5, name: "reserved", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6, name: "repeated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ } + ]); + } + create(value?: PartialMessage): ExtensionRangeOptions_Declaration { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ExtensionRangeOptions_Declaration): ExtensionRangeOptions_Declaration { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional int32 number */ 1: + message.number = reader.int32(); + break; + case /* optional string full_name */ 2: + message.fullName = reader.string(); + break; + case /* optional string type */ 3: + message.type = reader.string(); + break; + case /* optional bool reserved */ 5: + message.reserved = reader.bool(); + break; + case /* optional bool repeated */ 6: + message.repeated = reader.bool(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: ExtensionRangeOptions_Declaration, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional int32 number = 1; */ + if (message.number !== undefined) + writer.tag(1, WireType.Varint).int32(message.number); + /* optional string full_name = 2; */ + if (message.fullName !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.fullName); + /* optional string type = 3; */ + if (message.type !== undefined) + writer.tag(3, WireType.LengthDelimited).string(message.type); + /* optional bool reserved = 5; */ + if (message.reserved !== undefined) + writer.tag(5, WireType.Varint).bool(message.reserved); + /* optional bool repeated = 6; */ + if (message.repeated !== undefined) + writer.tag(6, WireType.Varint).bool(message.repeated); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.ExtensionRangeOptions.Declaration + */ +export const ExtensionRangeOptions_Declaration = new ExtensionRangeOptions_Declaration$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FieldDescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.FieldDescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "number", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 4, name: "label", kind: "enum", opt: true, T: () => ["google.protobuf.FieldDescriptorProto.Label", FieldDescriptorProto_Label, "LABEL_"] }, + { no: 5, name: "type", kind: "enum", opt: true, T: () => ["google.protobuf.FieldDescriptorProto.Type", FieldDescriptorProto_Type, "TYPE_"] }, + { no: 6, name: "type_name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "extendee", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 7, name: "default_value", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 9, name: "oneof_index", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 10, name: "json_name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 8, name: "options", kind: "message", T: () => FieldOptions }, + { no: 17, name: "proto3_optional", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ } + ]); + } + create(value?: PartialMessage): FieldDescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FieldDescriptorProto): FieldDescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* optional int32 number */ 3: + message.number = reader.int32(); + break; + case /* optional google.protobuf.FieldDescriptorProto.Label label */ 4: + message.label = reader.int32(); + break; + case /* optional google.protobuf.FieldDescriptorProto.Type type */ 5: + message.type = reader.int32(); + break; + case /* optional string type_name */ 6: + message.typeName = reader.string(); + break; + case /* optional string extendee */ 2: + message.extendee = reader.string(); + break; + case /* optional string default_value */ 7: + message.defaultValue = reader.string(); + break; + case /* optional int32 oneof_index */ 9: + message.oneofIndex = reader.int32(); + break; + case /* optional string json_name */ 10: + message.jsonName = reader.string(); + break; + case /* optional google.protobuf.FieldOptions options */ 8: + message.options = FieldOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + case /* optional bool proto3_optional */ 17: + message.proto3Optional = reader.bool(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FieldDescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* optional string extendee = 2; */ + if (message.extendee !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.extendee); + /* optional int32 number = 3; */ + if (message.number !== undefined) + writer.tag(3, WireType.Varint).int32(message.number); + /* optional google.protobuf.FieldDescriptorProto.Label label = 4; */ + if (message.label !== undefined) + writer.tag(4, WireType.Varint).int32(message.label); + /* optional google.protobuf.FieldDescriptorProto.Type type = 5; */ + if (message.type !== undefined) + writer.tag(5, WireType.Varint).int32(message.type); + /* optional string type_name = 6; */ + if (message.typeName !== undefined) + writer.tag(6, WireType.LengthDelimited).string(message.typeName); + /* optional string default_value = 7; */ + if (message.defaultValue !== undefined) + writer.tag(7, WireType.LengthDelimited).string(message.defaultValue); + /* optional google.protobuf.FieldOptions options = 8; */ + if (message.options) + FieldOptions.internalBinaryWrite(message.options, writer.tag(8, WireType.LengthDelimited).fork(), options).join(); + /* optional int32 oneof_index = 9; */ + if (message.oneofIndex !== undefined) + writer.tag(9, WireType.Varint).int32(message.oneofIndex); + /* optional string json_name = 10; */ + if (message.jsonName !== undefined) + writer.tag(10, WireType.LengthDelimited).string(message.jsonName); + /* optional bool proto3_optional = 17; */ + if (message.proto3Optional !== undefined) + writer.tag(17, WireType.Varint).bool(message.proto3Optional); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FieldDescriptorProto + */ +export const FieldDescriptorProto = new FieldDescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class OneofDescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.OneofDescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "options", kind: "message", T: () => OneofOptions } + ]); + } + create(value?: PartialMessage): OneofDescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: OneofDescriptorProto): OneofDescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* optional google.protobuf.OneofOptions options */ 2: + message.options = OneofOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: OneofDescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* optional google.protobuf.OneofOptions options = 2; */ + if (message.options) + OneofOptions.internalBinaryWrite(message.options, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.OneofDescriptorProto + */ +export const OneofDescriptorProto = new OneofDescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class EnumDescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.EnumDescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "value", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EnumValueDescriptorProto }, + { no: 3, name: "options", kind: "message", T: () => EnumOptions }, + { no: 4, name: "reserved_range", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EnumDescriptorProto_EnumReservedRange }, + { no: 5, name: "reserved_name", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): EnumDescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + message.value = []; + message.reservedRange = []; + message.reservedName = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: EnumDescriptorProto): EnumDescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* repeated google.protobuf.EnumValueDescriptorProto value */ 2: + message.value.push(EnumValueDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional google.protobuf.EnumOptions options */ 3: + message.options = EnumOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + case /* repeated google.protobuf.EnumDescriptorProto.EnumReservedRange reserved_range */ 4: + message.reservedRange.push(EnumDescriptorProto_EnumReservedRange.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* repeated string reserved_name */ 5: + message.reservedName.push(reader.string()); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: EnumDescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* repeated google.protobuf.EnumValueDescriptorProto value = 2; */ + for (let i = 0; i < message.value.length; i++) + EnumValueDescriptorProto.internalBinaryWrite(message.value[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.EnumOptions options = 3; */ + if (message.options) + EnumOptions.internalBinaryWrite(message.options, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.EnumDescriptorProto.EnumReservedRange reserved_range = 4; */ + for (let i = 0; i < message.reservedRange.length; i++) + EnumDescriptorProto_EnumReservedRange.internalBinaryWrite(message.reservedRange[i], writer.tag(4, WireType.LengthDelimited).fork(), options).join(); + /* repeated string reserved_name = 5; */ + for (let i = 0; i < message.reservedName.length; i++) + writer.tag(5, WireType.LengthDelimited).string(message.reservedName[i]); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.EnumDescriptorProto + */ +export const EnumDescriptorProto = new EnumDescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class EnumDescriptorProto_EnumReservedRange$Type extends MessageType { + constructor() { + super("google.protobuf.EnumDescriptorProto.EnumReservedRange", [ + { no: 1, name: "start", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 2, name: "end", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ } + ]); + } + create(value?: PartialMessage): EnumDescriptorProto_EnumReservedRange { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: EnumDescriptorProto_EnumReservedRange): EnumDescriptorProto_EnumReservedRange { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional int32 start */ 1: + message.start = reader.int32(); + break; + case /* optional int32 end */ 2: + message.end = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: EnumDescriptorProto_EnumReservedRange, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional int32 start = 1; */ + if (message.start !== undefined) + writer.tag(1, WireType.Varint).int32(message.start); + /* optional int32 end = 2; */ + if (message.end !== undefined) + writer.tag(2, WireType.Varint).int32(message.end); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.EnumDescriptorProto.EnumReservedRange + */ +export const EnumDescriptorProto_EnumReservedRange = new EnumDescriptorProto_EnumReservedRange$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class EnumValueDescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.EnumValueDescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "number", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 3, name: "options", kind: "message", T: () => EnumValueOptions } + ]); + } + create(value?: PartialMessage): EnumValueDescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: EnumValueDescriptorProto): EnumValueDescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* optional int32 number */ 2: + message.number = reader.int32(); + break; + case /* optional google.protobuf.EnumValueOptions options */ 3: + message.options = EnumValueOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: EnumValueDescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* optional int32 number = 2; */ + if (message.number !== undefined) + writer.tag(2, WireType.Varint).int32(message.number); + /* optional google.protobuf.EnumValueOptions options = 3; */ + if (message.options) + EnumValueOptions.internalBinaryWrite(message.options, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.EnumValueDescriptorProto + */ +export const EnumValueDescriptorProto = new EnumValueDescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ServiceDescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.ServiceDescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "method", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => MethodDescriptorProto }, + { no: 3, name: "options", kind: "message", T: () => ServiceOptions } + ]); + } + create(value?: PartialMessage): ServiceDescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + message.method = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ServiceDescriptorProto): ServiceDescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* repeated google.protobuf.MethodDescriptorProto method */ 2: + message.method.push(MethodDescriptorProto.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional google.protobuf.ServiceOptions options */ 3: + message.options = ServiceOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: ServiceDescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* repeated google.protobuf.MethodDescriptorProto method = 2; */ + for (let i = 0; i < message.method.length; i++) + MethodDescriptorProto.internalBinaryWrite(message.method[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.ServiceOptions options = 3; */ + if (message.options) + ServiceOptions.internalBinaryWrite(message.options, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.ServiceDescriptorProto + */ +export const ServiceDescriptorProto = new ServiceDescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class MethodDescriptorProto$Type extends MessageType { + constructor() { + super("google.protobuf.MethodDescriptorProto", [ + { no: 1, name: "name", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "input_type", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "output_type", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 4, name: "options", kind: "message", T: () => MethodOptions }, + { no: 5, name: "client_streaming", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6, name: "server_streaming", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ } + ]); + } + create(value?: PartialMessage): MethodDescriptorProto { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MethodDescriptorProto): MethodDescriptorProto { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string name */ 1: + message.name = reader.string(); + break; + case /* optional string input_type */ 2: + message.inputType = reader.string(); + break; + case /* optional string output_type */ 3: + message.outputType = reader.string(); + break; + case /* optional google.protobuf.MethodOptions options */ 4: + message.options = MethodOptions.internalBinaryRead(reader, reader.uint32(), options, message.options); + break; + case /* optional bool client_streaming = 5 [default = false] */ 5: + message.clientStreaming = reader.bool(); + break; + case /* optional bool server_streaming = 6 [default = false] */ 6: + message.serverStreaming = reader.bool(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: MethodDescriptorProto, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string name = 1; */ + if (message.name !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.name); + /* optional string input_type = 2; */ + if (message.inputType !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.inputType); + /* optional string output_type = 3; */ + if (message.outputType !== undefined) + writer.tag(3, WireType.LengthDelimited).string(message.outputType); + /* optional google.protobuf.MethodOptions options = 4; */ + if (message.options) + MethodOptions.internalBinaryWrite(message.options, writer.tag(4, WireType.LengthDelimited).fork(), options).join(); + /* optional bool client_streaming = 5 [default = false]; */ + if (message.clientStreaming !== undefined) + writer.tag(5, WireType.Varint).bool(message.clientStreaming); + /* optional bool server_streaming = 6 [default = false]; */ + if (message.serverStreaming !== undefined) + writer.tag(6, WireType.Varint).bool(message.serverStreaming); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.MethodDescriptorProto + */ +export const MethodDescriptorProto = new MethodDescriptorProto$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FileOptions$Type extends MessageType { + constructor() { + super("google.protobuf.FileOptions", [ + { no: 1, name: "java_package", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 8, name: "java_outer_classname", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 10, name: "java_multiple_files", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 20, name: "java_generate_equals_and_hash", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 27, name: "java_string_check_utf8", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 9, name: "optimize_for", kind: "enum", opt: true, T: () => ["google.protobuf.FileOptions.OptimizeMode", FileOptions_OptimizeMode] }, + { no: 11, name: "go_package", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 16, name: "cc_generic_services", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 17, name: "java_generic_services", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 18, name: "py_generic_services", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 23, name: "deprecated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 31, name: "cc_enable_arenas", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 36, name: "objc_class_prefix", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 37, name: "csharp_namespace", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 39, name: "swift_prefix", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 40, name: "php_class_prefix", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 41, name: "php_namespace", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 44, name: "php_metadata_namespace", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 45, name: "ruby_package", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 50, name: "features", kind: "message", T: () => FeatureSet }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): FileOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FileOptions): FileOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional string java_package */ 1: + message.javaPackage = reader.string(); + break; + case /* optional string java_outer_classname */ 8: + message.javaOuterClassname = reader.string(); + break; + case /* optional bool java_multiple_files = 10 [default = false] */ 10: + message.javaMultipleFiles = reader.bool(); + break; + case /* optional bool java_generate_equals_and_hash = 20 [deprecated = true] */ 20: + message.javaGenerateEqualsAndHash = reader.bool(); + break; + case /* optional bool java_string_check_utf8 = 27 [default = false] */ 27: + message.javaStringCheckUtf8 = reader.bool(); + break; + case /* optional google.protobuf.FileOptions.OptimizeMode optimize_for = 9 [default = SPEED] */ 9: + message.optimizeFor = reader.int32(); + break; + case /* optional string go_package */ 11: + message.goPackage = reader.string(); + break; + case /* optional bool cc_generic_services = 16 [default = false] */ 16: + message.ccGenericServices = reader.bool(); + break; + case /* optional bool java_generic_services = 17 [default = false] */ 17: + message.javaGenericServices = reader.bool(); + break; + case /* optional bool py_generic_services = 18 [default = false] */ 18: + message.pyGenericServices = reader.bool(); + break; + case /* optional bool deprecated = 23 [default = false] */ 23: + message.deprecated = reader.bool(); + break; + case /* optional bool cc_enable_arenas = 31 [default = true] */ 31: + message.ccEnableArenas = reader.bool(); + break; + case /* optional string objc_class_prefix */ 36: + message.objcClassPrefix = reader.string(); + break; + case /* optional string csharp_namespace */ 37: + message.csharpNamespace = reader.string(); + break; + case /* optional string swift_prefix */ 39: + message.swiftPrefix = reader.string(); + break; + case /* optional string php_class_prefix */ 40: + message.phpClassPrefix = reader.string(); + break; + case /* optional string php_namespace */ 41: + message.phpNamespace = reader.string(); + break; + case /* optional string php_metadata_namespace */ 44: + message.phpMetadataNamespace = reader.string(); + break; + case /* optional string ruby_package */ 45: + message.rubyPackage = reader.string(); + break; + case /* optional google.protobuf.FeatureSet features */ 50: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FileOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string java_package = 1; */ + if (message.javaPackage !== undefined) + writer.tag(1, WireType.LengthDelimited).string(message.javaPackage); + /* optional string java_outer_classname = 8; */ + if (message.javaOuterClassname !== undefined) + writer.tag(8, WireType.LengthDelimited).string(message.javaOuterClassname); + /* optional google.protobuf.FileOptions.OptimizeMode optimize_for = 9 [default = SPEED]; */ + if (message.optimizeFor !== undefined) + writer.tag(9, WireType.Varint).int32(message.optimizeFor); + /* optional bool java_multiple_files = 10 [default = false]; */ + if (message.javaMultipleFiles !== undefined) + writer.tag(10, WireType.Varint).bool(message.javaMultipleFiles); + /* optional string go_package = 11; */ + if (message.goPackage !== undefined) + writer.tag(11, WireType.LengthDelimited).string(message.goPackage); + /* optional bool cc_generic_services = 16 [default = false]; */ + if (message.ccGenericServices !== undefined) + writer.tag(16, WireType.Varint).bool(message.ccGenericServices); + /* optional bool java_generic_services = 17 [default = false]; */ + if (message.javaGenericServices !== undefined) + writer.tag(17, WireType.Varint).bool(message.javaGenericServices); + /* optional bool py_generic_services = 18 [default = false]; */ + if (message.pyGenericServices !== undefined) + writer.tag(18, WireType.Varint).bool(message.pyGenericServices); + /* optional bool java_generate_equals_and_hash = 20 [deprecated = true]; */ + if (message.javaGenerateEqualsAndHash !== undefined) + writer.tag(20, WireType.Varint).bool(message.javaGenerateEqualsAndHash); + /* optional bool deprecated = 23 [default = false]; */ + if (message.deprecated !== undefined) + writer.tag(23, WireType.Varint).bool(message.deprecated); + /* optional bool java_string_check_utf8 = 27 [default = false]; */ + if (message.javaStringCheckUtf8 !== undefined) + writer.tag(27, WireType.Varint).bool(message.javaStringCheckUtf8); + /* optional bool cc_enable_arenas = 31 [default = true]; */ + if (message.ccEnableArenas !== undefined) + writer.tag(31, WireType.Varint).bool(message.ccEnableArenas); + /* optional string objc_class_prefix = 36; */ + if (message.objcClassPrefix !== undefined) + writer.tag(36, WireType.LengthDelimited).string(message.objcClassPrefix); + /* optional string csharp_namespace = 37; */ + if (message.csharpNamespace !== undefined) + writer.tag(37, WireType.LengthDelimited).string(message.csharpNamespace); + /* optional string swift_prefix = 39; */ + if (message.swiftPrefix !== undefined) + writer.tag(39, WireType.LengthDelimited).string(message.swiftPrefix); + /* optional string php_class_prefix = 40; */ + if (message.phpClassPrefix !== undefined) + writer.tag(40, WireType.LengthDelimited).string(message.phpClassPrefix); + /* optional string php_namespace = 41; */ + if (message.phpNamespace !== undefined) + writer.tag(41, WireType.LengthDelimited).string(message.phpNamespace); + /* optional string php_metadata_namespace = 44; */ + if (message.phpMetadataNamespace !== undefined) + writer.tag(44, WireType.LengthDelimited).string(message.phpMetadataNamespace); + /* optional string ruby_package = 45; */ + if (message.rubyPackage !== undefined) + writer.tag(45, WireType.LengthDelimited).string(message.rubyPackage); + /* optional google.protobuf.FeatureSet features = 50; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(50, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FileOptions + */ +export const FileOptions = new FileOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class MessageOptions$Type extends MessageType { + constructor() { + super("google.protobuf.MessageOptions", [ + { no: 1, name: "message_set_wire_format", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 2, name: "no_standard_descriptor_accessor", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 3, name: "deprecated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 7, name: "map_entry", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 11, name: "deprecated_legacy_json_field_conflicts", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 12, name: "features", kind: "message", T: () => FeatureSet }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): MessageOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MessageOptions): MessageOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional bool message_set_wire_format = 1 [default = false] */ 1: + message.messageSetWireFormat = reader.bool(); + break; + case /* optional bool no_standard_descriptor_accessor = 2 [default = false] */ 2: + message.noStandardDescriptorAccessor = reader.bool(); + break; + case /* optional bool deprecated = 3 [default = false] */ 3: + message.deprecated = reader.bool(); + break; + case /* optional bool map_entry */ 7: + message.mapEntry = reader.bool(); + break; + case /* optional bool deprecated_legacy_json_field_conflicts = 11 [deprecated = true] */ 11: + message.deprecatedLegacyJsonFieldConflicts = reader.bool(); + break; + case /* optional google.protobuf.FeatureSet features */ 12: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: MessageOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional bool message_set_wire_format = 1 [default = false]; */ + if (message.messageSetWireFormat !== undefined) + writer.tag(1, WireType.Varint).bool(message.messageSetWireFormat); + /* optional bool no_standard_descriptor_accessor = 2 [default = false]; */ + if (message.noStandardDescriptorAccessor !== undefined) + writer.tag(2, WireType.Varint).bool(message.noStandardDescriptorAccessor); + /* optional bool deprecated = 3 [default = false]; */ + if (message.deprecated !== undefined) + writer.tag(3, WireType.Varint).bool(message.deprecated); + /* optional bool map_entry = 7; */ + if (message.mapEntry !== undefined) + writer.tag(7, WireType.Varint).bool(message.mapEntry); + /* optional bool deprecated_legacy_json_field_conflicts = 11 [deprecated = true]; */ + if (message.deprecatedLegacyJsonFieldConflicts !== undefined) + writer.tag(11, WireType.Varint).bool(message.deprecatedLegacyJsonFieldConflicts); + /* optional google.protobuf.FeatureSet features = 12; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(12, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.MessageOptions + */ +export const MessageOptions = new MessageOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FieldOptions$Type extends MessageType { + constructor() { + super("google.protobuf.FieldOptions", [ + { no: 1, name: "ctype", kind: "enum", opt: true, T: () => ["google.protobuf.FieldOptions.CType", FieldOptions_CType] }, + { no: 2, name: "packed", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6, name: "jstype", kind: "enum", opt: true, T: () => ["google.protobuf.FieldOptions.JSType", FieldOptions_JSType] }, + { no: 5, name: "lazy", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 15, name: "unverified_lazy", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 3, name: "deprecated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 10, name: "weak", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 16, name: "debug_redact", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 17, name: "retention", kind: "enum", opt: true, T: () => ["google.protobuf.FieldOptions.OptionRetention", FieldOptions_OptionRetention] }, + { no: 19, name: "targets", kind: "enum", repeat: 2 /*RepeatType.UNPACKED*/, T: () => ["google.protobuf.FieldOptions.OptionTargetType", FieldOptions_OptionTargetType] }, + { no: 20, name: "edition_defaults", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => FieldOptions_EditionDefault }, + { no: 21, name: "features", kind: "message", T: () => FeatureSet }, + { no: 22, name: "feature_support", kind: "message", T: () => FieldOptions_FeatureSupport }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): FieldOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.targets = []; + message.editionDefaults = []; + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FieldOptions): FieldOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional google.protobuf.FieldOptions.CType ctype = 1 [default = STRING] */ 1: + message.ctype = reader.int32(); + break; + case /* optional bool packed */ 2: + message.packed = reader.bool(); + break; + case /* optional google.protobuf.FieldOptions.JSType jstype = 6 [default = JS_NORMAL] */ 6: + message.jstype = reader.int32(); + break; + case /* optional bool lazy = 5 [default = false] */ 5: + message.lazy = reader.bool(); + break; + case /* optional bool unverified_lazy = 15 [default = false] */ 15: + message.unverifiedLazy = reader.bool(); + break; + case /* optional bool deprecated = 3 [default = false] */ 3: + message.deprecated = reader.bool(); + break; + case /* optional bool weak = 10 [default = false] */ 10: + message.weak = reader.bool(); + break; + case /* optional bool debug_redact = 16 [default = false] */ 16: + message.debugRedact = reader.bool(); + break; + case /* optional google.protobuf.FieldOptions.OptionRetention retention */ 17: + message.retention = reader.int32(); + break; + case /* repeated google.protobuf.FieldOptions.OptionTargetType targets */ 19: + if (wireType === WireType.LengthDelimited) + for (let e = reader.int32() + reader.pos; reader.pos < e;) + message.targets.push(reader.int32()); + else + message.targets.push(reader.int32()); + break; + case /* repeated google.protobuf.FieldOptions.EditionDefault edition_defaults */ 20: + message.editionDefaults.push(FieldOptions_EditionDefault.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional google.protobuf.FeatureSet features */ 21: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* optional google.protobuf.FieldOptions.FeatureSupport feature_support */ 22: + message.featureSupport = FieldOptions_FeatureSupport.internalBinaryRead(reader, reader.uint32(), options, message.featureSupport); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FieldOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional google.protobuf.FieldOptions.CType ctype = 1 [default = STRING]; */ + if (message.ctype !== undefined) + writer.tag(1, WireType.Varint).int32(message.ctype); + /* optional bool packed = 2; */ + if (message.packed !== undefined) + writer.tag(2, WireType.Varint).bool(message.packed); + /* optional bool deprecated = 3 [default = false]; */ + if (message.deprecated !== undefined) + writer.tag(3, WireType.Varint).bool(message.deprecated); + /* optional bool lazy = 5 [default = false]; */ + if (message.lazy !== undefined) + writer.tag(5, WireType.Varint).bool(message.lazy); + /* optional google.protobuf.FieldOptions.JSType jstype = 6 [default = JS_NORMAL]; */ + if (message.jstype !== undefined) + writer.tag(6, WireType.Varint).int32(message.jstype); + /* optional bool weak = 10 [default = false]; */ + if (message.weak !== undefined) + writer.tag(10, WireType.Varint).bool(message.weak); + /* optional bool unverified_lazy = 15 [default = false]; */ + if (message.unverifiedLazy !== undefined) + writer.tag(15, WireType.Varint).bool(message.unverifiedLazy); + /* optional bool debug_redact = 16 [default = false]; */ + if (message.debugRedact !== undefined) + writer.tag(16, WireType.Varint).bool(message.debugRedact); + /* optional google.protobuf.FieldOptions.OptionRetention retention = 17; */ + if (message.retention !== undefined) + writer.tag(17, WireType.Varint).int32(message.retention); + /* repeated google.protobuf.FieldOptions.OptionTargetType targets = 19; */ + for (let i = 0; i < message.targets.length; i++) + writer.tag(19, WireType.Varint).int32(message.targets[i]); + /* repeated google.protobuf.FieldOptions.EditionDefault edition_defaults = 20; */ + for (let i = 0; i < message.editionDefaults.length; i++) + FieldOptions_EditionDefault.internalBinaryWrite(message.editionDefaults[i], writer.tag(20, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.FeatureSet features = 21; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(21, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.FieldOptions.FeatureSupport feature_support = 22; */ + if (message.featureSupport) + FieldOptions_FeatureSupport.internalBinaryWrite(message.featureSupport, writer.tag(22, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FieldOptions + */ +export const FieldOptions = new FieldOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FieldOptions_EditionDefault$Type extends MessageType { + constructor() { + super("google.protobuf.FieldOptions.EditionDefault", [ + { no: 3, name: "edition", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] }, + { no: 2, name: "value", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): FieldOptions_EditionDefault { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FieldOptions_EditionDefault): FieldOptions_EditionDefault { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional google.protobuf.Edition edition */ 3: + message.edition = reader.int32(); + break; + case /* optional string value */ 2: + message.value = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FieldOptions_EditionDefault, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional string value = 2; */ + if (message.value !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.value); + /* optional google.protobuf.Edition edition = 3; */ + if (message.edition !== undefined) + writer.tag(3, WireType.Varint).int32(message.edition); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FieldOptions.EditionDefault + */ +export const FieldOptions_EditionDefault = new FieldOptions_EditionDefault$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FieldOptions_FeatureSupport$Type extends MessageType { + constructor() { + super("google.protobuf.FieldOptions.FeatureSupport", [ + { no: 1, name: "edition_introduced", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] }, + { no: 2, name: "edition_deprecated", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] }, + { no: 3, name: "deprecation_warning", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 4, name: "edition_removed", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] } + ]); + } + create(value?: PartialMessage): FieldOptions_FeatureSupport { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FieldOptions_FeatureSupport): FieldOptions_FeatureSupport { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional google.protobuf.Edition edition_introduced */ 1: + message.editionIntroduced = reader.int32(); + break; + case /* optional google.protobuf.Edition edition_deprecated */ 2: + message.editionDeprecated = reader.int32(); + break; + case /* optional string deprecation_warning */ 3: + message.deprecationWarning = reader.string(); + break; + case /* optional google.protobuf.Edition edition_removed */ 4: + message.editionRemoved = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FieldOptions_FeatureSupport, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional google.protobuf.Edition edition_introduced = 1; */ + if (message.editionIntroduced !== undefined) + writer.tag(1, WireType.Varint).int32(message.editionIntroduced); + /* optional google.protobuf.Edition edition_deprecated = 2; */ + if (message.editionDeprecated !== undefined) + writer.tag(2, WireType.Varint).int32(message.editionDeprecated); + /* optional string deprecation_warning = 3; */ + if (message.deprecationWarning !== undefined) + writer.tag(3, WireType.LengthDelimited).string(message.deprecationWarning); + /* optional google.protobuf.Edition edition_removed = 4; */ + if (message.editionRemoved !== undefined) + writer.tag(4, WireType.Varint).int32(message.editionRemoved); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FieldOptions.FeatureSupport + */ +export const FieldOptions_FeatureSupport = new FieldOptions_FeatureSupport$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class OneofOptions$Type extends MessageType { + constructor() { + super("google.protobuf.OneofOptions", [ + { no: 1, name: "features", kind: "message", T: () => FeatureSet }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): OneofOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: OneofOptions): OneofOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional google.protobuf.FeatureSet features */ 1: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: OneofOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional google.protobuf.FeatureSet features = 1; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.OneofOptions + */ +export const OneofOptions = new OneofOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class EnumOptions$Type extends MessageType { + constructor() { + super("google.protobuf.EnumOptions", [ + { no: 2, name: "allow_alias", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 3, name: "deprecated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6, name: "deprecated_legacy_json_field_conflicts", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 7, name: "features", kind: "message", T: () => FeatureSet }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): EnumOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: EnumOptions): EnumOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional bool allow_alias */ 2: + message.allowAlias = reader.bool(); + break; + case /* optional bool deprecated = 3 [default = false] */ 3: + message.deprecated = reader.bool(); + break; + case /* optional bool deprecated_legacy_json_field_conflicts = 6 [deprecated = true] */ 6: + message.deprecatedLegacyJsonFieldConflicts = reader.bool(); + break; + case /* optional google.protobuf.FeatureSet features */ 7: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: EnumOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional bool allow_alias = 2; */ + if (message.allowAlias !== undefined) + writer.tag(2, WireType.Varint).bool(message.allowAlias); + /* optional bool deprecated = 3 [default = false]; */ + if (message.deprecated !== undefined) + writer.tag(3, WireType.Varint).bool(message.deprecated); + /* optional bool deprecated_legacy_json_field_conflicts = 6 [deprecated = true]; */ + if (message.deprecatedLegacyJsonFieldConflicts !== undefined) + writer.tag(6, WireType.Varint).bool(message.deprecatedLegacyJsonFieldConflicts); + /* optional google.protobuf.FeatureSet features = 7; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(7, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.EnumOptions + */ +export const EnumOptions = new EnumOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class EnumValueOptions$Type extends MessageType { + constructor() { + super("google.protobuf.EnumValueOptions", [ + { no: 1, name: "deprecated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 2, name: "features", kind: "message", T: () => FeatureSet }, + { no: 3, name: "debug_redact", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 4, name: "feature_support", kind: "message", T: () => FieldOptions_FeatureSupport }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): EnumValueOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: EnumValueOptions): EnumValueOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional bool deprecated = 1 [default = false] */ 1: + message.deprecated = reader.bool(); + break; + case /* optional google.protobuf.FeatureSet features */ 2: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* optional bool debug_redact = 3 [default = false] */ 3: + message.debugRedact = reader.bool(); + break; + case /* optional google.protobuf.FieldOptions.FeatureSupport feature_support */ 4: + message.featureSupport = FieldOptions_FeatureSupport.internalBinaryRead(reader, reader.uint32(), options, message.featureSupport); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: EnumValueOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional bool deprecated = 1 [default = false]; */ + if (message.deprecated !== undefined) + writer.tag(1, WireType.Varint).bool(message.deprecated); + /* optional google.protobuf.FeatureSet features = 2; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional bool debug_redact = 3 [default = false]; */ + if (message.debugRedact !== undefined) + writer.tag(3, WireType.Varint).bool(message.debugRedact); + /* optional google.protobuf.FieldOptions.FeatureSupport feature_support = 4; */ + if (message.featureSupport) + FieldOptions_FeatureSupport.internalBinaryWrite(message.featureSupport, writer.tag(4, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.EnumValueOptions + */ +export const EnumValueOptions = new EnumValueOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ServiceOptions$Type extends MessageType { + constructor() { + super("google.protobuf.ServiceOptions", [ + { no: 34, name: "features", kind: "message", T: () => FeatureSet }, + { no: 33, name: "deprecated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): ServiceOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ServiceOptions): ServiceOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional google.protobuf.FeatureSet features */ 34: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* optional bool deprecated = 33 [default = false] */ 33: + message.deprecated = reader.bool(); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: ServiceOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional bool deprecated = 33 [default = false]; */ + if (message.deprecated !== undefined) + writer.tag(33, WireType.Varint).bool(message.deprecated); + /* optional google.protobuf.FeatureSet features = 34; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(34, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.ServiceOptions + */ +export const ServiceOptions = new ServiceOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class MethodOptions$Type extends MessageType { + constructor() { + super("google.protobuf.MethodOptions", [ + { no: 33, name: "deprecated", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 34, name: "idempotency_level", kind: "enum", opt: true, T: () => ["google.protobuf.MethodOptions.IdempotencyLevel", MethodOptions_IdempotencyLevel] }, + { no: 35, name: "features", kind: "message", T: () => FeatureSet }, + { no: 999, name: "uninterpreted_option", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption } + ]); + } + create(value?: PartialMessage): MethodOptions { + const message = globalThis.Object.create((this.messagePrototype!)); + message.uninterpretedOption = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MethodOptions): MethodOptions { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional bool deprecated = 33 [default = false] */ 33: + message.deprecated = reader.bool(); + break; + case /* optional google.protobuf.MethodOptions.IdempotencyLevel idempotency_level = 34 [default = IDEMPOTENCY_UNKNOWN] */ 34: + message.idempotencyLevel = reader.int32(); + break; + case /* optional google.protobuf.FeatureSet features */ 35: + message.features = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.features); + break; + case /* repeated google.protobuf.UninterpretedOption uninterpreted_option */ 999: + message.uninterpretedOption.push(UninterpretedOption.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: MethodOptions, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional bool deprecated = 33 [default = false]; */ + if (message.deprecated !== undefined) + writer.tag(33, WireType.Varint).bool(message.deprecated); + /* optional google.protobuf.MethodOptions.IdempotencyLevel idempotency_level = 34 [default = IDEMPOTENCY_UNKNOWN]; */ + if (message.idempotencyLevel !== undefined) + writer.tag(34, WireType.Varint).int32(message.idempotencyLevel); + /* optional google.protobuf.FeatureSet features = 35; */ + if (message.features) + FeatureSet.internalBinaryWrite(message.features, writer.tag(35, WireType.LengthDelimited).fork(), options).join(); + /* repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; */ + for (let i = 0; i < message.uninterpretedOption.length; i++) + UninterpretedOption.internalBinaryWrite(message.uninterpretedOption[i], writer.tag(999, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.MethodOptions + */ +export const MethodOptions = new MethodOptions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class UninterpretedOption$Type extends MessageType { + constructor() { + super("google.protobuf.UninterpretedOption", [ + { no: 2, name: "name", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => UninterpretedOption_NamePart }, + { no: 3, name: "identifier_value", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 4, name: "positive_int_value", kind: "scalar", opt: true, T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 5, name: "negative_int_value", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 6, name: "double_value", kind: "scalar", opt: true, T: 1 /*ScalarType.DOUBLE*/ }, + { no: 7, name: "string_value", kind: "scalar", opt: true, T: 12 /*ScalarType.BYTES*/ }, + { no: 8, name: "aggregate_value", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): UninterpretedOption { + const message = globalThis.Object.create((this.messagePrototype!)); + message.name = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: UninterpretedOption): UninterpretedOption { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated google.protobuf.UninterpretedOption.NamePart name */ 2: + message.name.push(UninterpretedOption_NamePart.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional string identifier_value */ 3: + message.identifierValue = reader.string(); + break; + case /* optional uint64 positive_int_value */ 4: + message.positiveIntValue = reader.uint64().toBigInt(); + break; + case /* optional int64 negative_int_value */ 5: + message.negativeIntValue = reader.int64().toBigInt(); + break; + case /* optional double double_value */ 6: + message.doubleValue = reader.double(); + break; + case /* optional bytes string_value */ 7: + message.stringValue = reader.bytes(); + break; + case /* optional string aggregate_value */ 8: + message.aggregateValue = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: UninterpretedOption, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated google.protobuf.UninterpretedOption.NamePart name = 2; */ + for (let i = 0; i < message.name.length; i++) + UninterpretedOption_NamePart.internalBinaryWrite(message.name[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional string identifier_value = 3; */ + if (message.identifierValue !== undefined) + writer.tag(3, WireType.LengthDelimited).string(message.identifierValue); + /* optional uint64 positive_int_value = 4; */ + if (message.positiveIntValue !== undefined) + writer.tag(4, WireType.Varint).uint64(message.positiveIntValue); + /* optional int64 negative_int_value = 5; */ + if (message.negativeIntValue !== undefined) + writer.tag(5, WireType.Varint).int64(message.negativeIntValue); + /* optional double double_value = 6; */ + if (message.doubleValue !== undefined) + writer.tag(6, WireType.Bit64).double(message.doubleValue); + /* optional bytes string_value = 7; */ + if (message.stringValue !== undefined) + writer.tag(7, WireType.LengthDelimited).bytes(message.stringValue); + /* optional string aggregate_value = 8; */ + if (message.aggregateValue !== undefined) + writer.tag(8, WireType.LengthDelimited).string(message.aggregateValue); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.UninterpretedOption + */ +export const UninterpretedOption = new UninterpretedOption$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class UninterpretedOption_NamePart$Type extends MessageType { + constructor() { + super("google.protobuf.UninterpretedOption.NamePart", [ + { no: 1, name: "name_part", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "is_extension", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } + ]); + } + create(value?: PartialMessage): UninterpretedOption_NamePart { + const message = globalThis.Object.create((this.messagePrototype!)); + message.namePart = ""; + message.isExtension = false; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: UninterpretedOption_NamePart): UninterpretedOption_NamePart { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* required string name_part */ 1: + message.namePart = reader.string(); + break; + case /* required bool is_extension */ 2: + message.isExtension = reader.bool(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: UninterpretedOption_NamePart, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* required string name_part = 1; */ + if (message.namePart !== "") + writer.tag(1, WireType.LengthDelimited).string(message.namePart); + /* required bool is_extension = 2; */ + if (message.isExtension !== false) + writer.tag(2, WireType.Varint).bool(message.isExtension); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.UninterpretedOption.NamePart + */ +export const UninterpretedOption_NamePart = new UninterpretedOption_NamePart$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FeatureSet$Type extends MessageType { + constructor() { + super("google.protobuf.FeatureSet", [ + { no: 1, name: "field_presence", kind: "enum", opt: true, T: () => ["google.protobuf.FeatureSet.FieldPresence", FeatureSet_FieldPresence] }, + { no: 2, name: "enum_type", kind: "enum", opt: true, T: () => ["google.protobuf.FeatureSet.EnumType", FeatureSet_EnumType] }, + { no: 3, name: "repeated_field_encoding", kind: "enum", opt: true, T: () => ["google.protobuf.FeatureSet.RepeatedFieldEncoding", FeatureSet_RepeatedFieldEncoding] }, + { no: 4, name: "utf8_validation", kind: "enum", opt: true, T: () => ["google.protobuf.FeatureSet.Utf8Validation", FeatureSet_Utf8Validation] }, + { no: 5, name: "message_encoding", kind: "enum", opt: true, T: () => ["google.protobuf.FeatureSet.MessageEncoding", FeatureSet_MessageEncoding] }, + { no: 6, name: "json_format", kind: "enum", opt: true, T: () => ["google.protobuf.FeatureSet.JsonFormat", FeatureSet_JsonFormat] } + ]); + } + create(value?: PartialMessage): FeatureSet { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FeatureSet): FeatureSet { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional google.protobuf.FeatureSet.FieldPresence field_presence */ 1: + message.fieldPresence = reader.int32(); + break; + case /* optional google.protobuf.FeatureSet.EnumType enum_type */ 2: + message.enumType = reader.int32(); + break; + case /* optional google.protobuf.FeatureSet.RepeatedFieldEncoding repeated_field_encoding */ 3: + message.repeatedFieldEncoding = reader.int32(); + break; + case /* optional google.protobuf.FeatureSet.Utf8Validation utf8_validation */ 4: + message.utf8Validation = reader.int32(); + break; + case /* optional google.protobuf.FeatureSet.MessageEncoding message_encoding */ 5: + message.messageEncoding = reader.int32(); + break; + case /* optional google.protobuf.FeatureSet.JsonFormat json_format */ 6: + message.jsonFormat = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FeatureSet, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional google.protobuf.FeatureSet.FieldPresence field_presence = 1; */ + if (message.fieldPresence !== undefined) + writer.tag(1, WireType.Varint).int32(message.fieldPresence); + /* optional google.protobuf.FeatureSet.EnumType enum_type = 2; */ + if (message.enumType !== undefined) + writer.tag(2, WireType.Varint).int32(message.enumType); + /* optional google.protobuf.FeatureSet.RepeatedFieldEncoding repeated_field_encoding = 3; */ + if (message.repeatedFieldEncoding !== undefined) + writer.tag(3, WireType.Varint).int32(message.repeatedFieldEncoding); + /* optional google.protobuf.FeatureSet.Utf8Validation utf8_validation = 4; */ + if (message.utf8Validation !== undefined) + writer.tag(4, WireType.Varint).int32(message.utf8Validation); + /* optional google.protobuf.FeatureSet.MessageEncoding message_encoding = 5; */ + if (message.messageEncoding !== undefined) + writer.tag(5, WireType.Varint).int32(message.messageEncoding); + /* optional google.protobuf.FeatureSet.JsonFormat json_format = 6; */ + if (message.jsonFormat !== undefined) + writer.tag(6, WireType.Varint).int32(message.jsonFormat); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FeatureSet + */ +export const FeatureSet = new FeatureSet$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FeatureSetDefaults$Type extends MessageType { + constructor() { + super("google.protobuf.FeatureSetDefaults", [ + { no: 1, name: "defaults", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => FeatureSetDefaults_FeatureSetEditionDefault }, + { no: 4, name: "minimum_edition", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] }, + { no: 5, name: "maximum_edition", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] } + ]); + } + create(value?: PartialMessage): FeatureSetDefaults { + const message = globalThis.Object.create((this.messagePrototype!)); + message.defaults = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FeatureSetDefaults): FeatureSetDefaults { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault defaults */ 1: + message.defaults.push(FeatureSetDefaults_FeatureSetEditionDefault.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional google.protobuf.Edition minimum_edition */ 4: + message.minimumEdition = reader.int32(); + break; + case /* optional google.protobuf.Edition maximum_edition */ 5: + message.maximumEdition = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FeatureSetDefaults, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault defaults = 1; */ + for (let i = 0; i < message.defaults.length; i++) + FeatureSetDefaults_FeatureSetEditionDefault.internalBinaryWrite(message.defaults[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.Edition minimum_edition = 4; */ + if (message.minimumEdition !== undefined) + writer.tag(4, WireType.Varint).int32(message.minimumEdition); + /* optional google.protobuf.Edition maximum_edition = 5; */ + if (message.maximumEdition !== undefined) + writer.tag(5, WireType.Varint).int32(message.maximumEdition); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FeatureSetDefaults + */ +export const FeatureSetDefaults = new FeatureSetDefaults$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FeatureSetDefaults_FeatureSetEditionDefault$Type extends MessageType { + constructor() { + super("google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault", [ + { no: 3, name: "edition", kind: "enum", opt: true, T: () => ["google.protobuf.Edition", Edition] }, + { no: 4, name: "overridable_features", kind: "message", T: () => FeatureSet }, + { no: 5, name: "fixed_features", kind: "message", T: () => FeatureSet } + ]); + } + create(value?: PartialMessage): FeatureSetDefaults_FeatureSetEditionDefault { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FeatureSetDefaults_FeatureSetEditionDefault): FeatureSetDefaults_FeatureSetEditionDefault { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional google.protobuf.Edition edition */ 3: + message.edition = reader.int32(); + break; + case /* optional google.protobuf.FeatureSet overridable_features */ 4: + message.overridableFeatures = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.overridableFeatures); + break; + case /* optional google.protobuf.FeatureSet fixed_features */ 5: + message.fixedFeatures = FeatureSet.internalBinaryRead(reader, reader.uint32(), options, message.fixedFeatures); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: FeatureSetDefaults_FeatureSetEditionDefault, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional google.protobuf.Edition edition = 3; */ + if (message.edition !== undefined) + writer.tag(3, WireType.Varint).int32(message.edition); + /* optional google.protobuf.FeatureSet overridable_features = 4; */ + if (message.overridableFeatures) + FeatureSet.internalBinaryWrite(message.overridableFeatures, writer.tag(4, WireType.LengthDelimited).fork(), options).join(); + /* optional google.protobuf.FeatureSet fixed_features = 5; */ + if (message.fixedFeatures) + FeatureSet.internalBinaryWrite(message.fixedFeatures, writer.tag(5, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault + */ +export const FeatureSetDefaults_FeatureSetEditionDefault = new FeatureSetDefaults_FeatureSetEditionDefault$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class SourceCodeInfo$Type extends MessageType { + constructor() { + super("google.protobuf.SourceCodeInfo", [ + { no: 1, name: "location", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => SourceCodeInfo_Location } + ]); + } + create(value?: PartialMessage): SourceCodeInfo { + const message = globalThis.Object.create((this.messagePrototype!)); + message.location = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SourceCodeInfo): SourceCodeInfo { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated google.protobuf.SourceCodeInfo.Location location */ 1: + message.location.push(SourceCodeInfo_Location.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: SourceCodeInfo, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated google.protobuf.SourceCodeInfo.Location location = 1; */ + for (let i = 0; i < message.location.length; i++) + SourceCodeInfo_Location.internalBinaryWrite(message.location[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.SourceCodeInfo + */ +export const SourceCodeInfo = new SourceCodeInfo$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class SourceCodeInfo_Location$Type extends MessageType { + constructor() { + super("google.protobuf.SourceCodeInfo.Location", [ + { no: 1, name: "path", kind: "scalar", repeat: 1 /*RepeatType.PACKED*/, T: 5 /*ScalarType.INT32*/ }, + { no: 2, name: "span", kind: "scalar", repeat: 1 /*RepeatType.PACKED*/, T: 5 /*ScalarType.INT32*/ }, + { no: 3, name: "leading_comments", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 4, name: "trailing_comments", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 6, name: "leading_detached_comments", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): SourceCodeInfo_Location { + const message = globalThis.Object.create((this.messagePrototype!)); + message.path = []; + message.span = []; + message.leadingDetachedComments = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SourceCodeInfo_Location): SourceCodeInfo_Location { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated int32 path = 1 [packed = true] */ 1: + if (wireType === WireType.LengthDelimited) + for (let e = reader.int32() + reader.pos; reader.pos < e;) + message.path.push(reader.int32()); + else + message.path.push(reader.int32()); + break; + case /* repeated int32 span = 2 [packed = true] */ 2: + if (wireType === WireType.LengthDelimited) + for (let e = reader.int32() + reader.pos; reader.pos < e;) + message.span.push(reader.int32()); + else + message.span.push(reader.int32()); + break; + case /* optional string leading_comments */ 3: + message.leadingComments = reader.string(); + break; + case /* optional string trailing_comments */ 4: + message.trailingComments = reader.string(); + break; + case /* repeated string leading_detached_comments */ 6: + message.leadingDetachedComments.push(reader.string()); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: SourceCodeInfo_Location, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated int32 path = 1 [packed = true]; */ + if (message.path.length) { + writer.tag(1, WireType.LengthDelimited).fork(); + for (let i = 0; i < message.path.length; i++) + writer.int32(message.path[i]); + writer.join(); + } + /* repeated int32 span = 2 [packed = true]; */ + if (message.span.length) { + writer.tag(2, WireType.LengthDelimited).fork(); + for (let i = 0; i < message.span.length; i++) + writer.int32(message.span[i]); + writer.join(); + } + /* optional string leading_comments = 3; */ + if (message.leadingComments !== undefined) + writer.tag(3, WireType.LengthDelimited).string(message.leadingComments); + /* optional string trailing_comments = 4; */ + if (message.trailingComments !== undefined) + writer.tag(4, WireType.LengthDelimited).string(message.trailingComments); + /* repeated string leading_detached_comments = 6; */ + for (let i = 0; i < message.leadingDetachedComments.length; i++) + writer.tag(6, WireType.LengthDelimited).string(message.leadingDetachedComments[i]); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.SourceCodeInfo.Location + */ +export const SourceCodeInfo_Location = new SourceCodeInfo_Location$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GeneratedCodeInfo$Type extends MessageType { + constructor() { + super("google.protobuf.GeneratedCodeInfo", [ + { no: 1, name: "annotation", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => GeneratedCodeInfo_Annotation } + ]); + } + create(value?: PartialMessage): GeneratedCodeInfo { + const message = globalThis.Object.create((this.messagePrototype!)); + message.annotation = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GeneratedCodeInfo): GeneratedCodeInfo { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated google.protobuf.GeneratedCodeInfo.Annotation annotation */ 1: + message.annotation.push(GeneratedCodeInfo_Annotation.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GeneratedCodeInfo, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated google.protobuf.GeneratedCodeInfo.Annotation annotation = 1; */ + for (let i = 0; i < message.annotation.length; i++) + GeneratedCodeInfo_Annotation.internalBinaryWrite(message.annotation[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.GeneratedCodeInfo + */ +export const GeneratedCodeInfo = new GeneratedCodeInfo$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GeneratedCodeInfo_Annotation$Type extends MessageType { + constructor() { + super("google.protobuf.GeneratedCodeInfo.Annotation", [ + { no: 1, name: "path", kind: "scalar", repeat: 1 /*RepeatType.PACKED*/, T: 5 /*ScalarType.INT32*/ }, + { no: 2, name: "source_file", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "begin", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 4, name: "end", kind: "scalar", opt: true, T: 5 /*ScalarType.INT32*/ }, + { no: 5, name: "semantic", kind: "enum", opt: true, T: () => ["google.protobuf.GeneratedCodeInfo.Annotation.Semantic", GeneratedCodeInfo_Annotation_Semantic] } + ]); + } + create(value?: PartialMessage): GeneratedCodeInfo_Annotation { + const message = globalThis.Object.create((this.messagePrototype!)); + message.path = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GeneratedCodeInfo_Annotation): GeneratedCodeInfo_Annotation { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated int32 path = 1 [packed = true] */ 1: + if (wireType === WireType.LengthDelimited) + for (let e = reader.int32() + reader.pos; reader.pos < e;) + message.path.push(reader.int32()); + else + message.path.push(reader.int32()); + break; + case /* optional string source_file */ 2: + message.sourceFile = reader.string(); + break; + case /* optional int32 begin */ 3: + message.begin = reader.int32(); + break; + case /* optional int32 end */ 4: + message.end = reader.int32(); + break; + case /* optional google.protobuf.GeneratedCodeInfo.Annotation.Semantic semantic */ 5: + message.semantic = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GeneratedCodeInfo_Annotation, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated int32 path = 1 [packed = true]; */ + if (message.path.length) { + writer.tag(1, WireType.LengthDelimited).fork(); + for (let i = 0; i < message.path.length; i++) + writer.int32(message.path[i]); + writer.join(); + } + /* optional string source_file = 2; */ + if (message.sourceFile !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.sourceFile); + /* optional int32 begin = 3; */ + if (message.begin !== undefined) + writer.tag(3, WireType.Varint).int32(message.begin); + /* optional int32 end = 4; */ + if (message.end !== undefined) + writer.tag(4, WireType.Varint).int32(message.end); + /* optional google.protobuf.GeneratedCodeInfo.Annotation.Semantic semantic = 5; */ + if (message.semantic !== undefined) + writer.tag(5, WireType.Varint).int32(message.semantic); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message google.protobuf.GeneratedCodeInfo.Annotation + */ +export const GeneratedCodeInfo_Annotation = new GeneratedCodeInfo_Annotation$Type(); diff --git a/internal/examples/grpcweb/frontend/src/generated/greeter.client.ts b/internal/examples/grpcweb/frontend/src/generated/greeter.client.ts new file mode 100644 index 000000000..41de1f85c --- /dev/null +++ b/internal/examples/grpcweb/frontend/src/generated/greeter.client.ts @@ -0,0 +1,62 @@ +// @generated by protobuf-ts 2.11.1 +// @generated from protobuf file "greeter.proto" (package "grpcweb.example.v1", syntax proto3) +// tslint:disable +import type { RpcTransport } from "@protobuf-ts/runtime-rpc"; +import type { ServiceInfo } from "@protobuf-ts/runtime-rpc"; +import { GreeterService } from "./greeter"; +import type { GoodbyeResponse } from "./greeter"; +import type { GoodbyeRequest } from "./greeter"; +import { stackIntercept } from "@protobuf-ts/runtime-rpc"; +import type { HelloResponse } from "./greeter"; +import type { HelloRequest } from "./greeter"; +import type { UnaryCall } from "@protobuf-ts/runtime-rpc"; +import type { RpcOptions } from "@protobuf-ts/runtime-rpc"; +/** + * GreeterService 提供简单的问候服务,用于测试 gRPC Web 实现 + * + * @generated from protobuf service grpcweb.example.v1.GreeterService + */ +export interface IGreeterServiceClient { + /** + * SayHello 返回问候语 + * + * @generated from protobuf rpc: SayHello + */ + sayHello(input: HelloRequest, options?: RpcOptions): UnaryCall; + /** + * SayGoodbye 返回告别语 + * + * @generated from protobuf rpc: SayGoodbye + */ + sayGoodbye(input: GoodbyeRequest, options?: RpcOptions): UnaryCall; +} +/** + * GreeterService 提供简单的问候服务,用于测试 gRPC Web 实现 + * + * @generated from protobuf service grpcweb.example.v1.GreeterService + */ +export class GreeterServiceClient implements IGreeterServiceClient, ServiceInfo { + typeName = GreeterService.typeName; + methods = GreeterService.methods; + options = GreeterService.options; + constructor(private readonly _transport: RpcTransport) { + } + /** + * SayHello 返回问候语 + * + * @generated from protobuf rpc: SayHello + */ + sayHello(input: HelloRequest, options?: RpcOptions): UnaryCall { + const method = this.methods[0], opt = this._transport.mergeOptions(options); + return stackIntercept("unary", this._transport, method, opt, input); + } + /** + * SayGoodbye 返回告别语 + * + * @generated from protobuf rpc: SayGoodbye + */ + sayGoodbye(input: GoodbyeRequest, options?: RpcOptions): UnaryCall { + const method = this.methods[1], opt = this._transport.mergeOptions(options); + return stackIntercept("unary", this._transport, method, opt, input); + } +} diff --git a/internal/examples/grpcweb/frontend/src/generated/greeter.ts b/internal/examples/grpcweb/frontend/src/generated/greeter.ts new file mode 100644 index 000000000..a7a1bfb96 --- /dev/null +++ b/internal/examples/grpcweb/frontend/src/generated/greeter.ts @@ -0,0 +1,288 @@ +// @generated by protobuf-ts 2.11.1 +// @generated from protobuf file "greeter.proto" (package "grpcweb.example.v1", syntax proto3) +// tslint:disable +import { ServiceType } from "@protobuf-ts/runtime-rpc"; +import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; +import type { IBinaryWriter } from "@protobuf-ts/runtime"; +import { WireType } from "@protobuf-ts/runtime"; +import type { BinaryReadOptions } from "@protobuf-ts/runtime"; +import type { IBinaryReader } from "@protobuf-ts/runtime"; +import { UnknownFieldHandler } from "@protobuf-ts/runtime"; +import type { PartialMessage } from "@protobuf-ts/runtime"; +import { reflectionMergePartial } from "@protobuf-ts/runtime"; +import { MessageType } from "@protobuf-ts/runtime"; +/** + * HelloRequest 是 SayHello 的请求消息 + * + * @generated from protobuf message grpcweb.example.v1.HelloRequest + */ +export interface HelloRequest { + /** + * 用户名称 + * + * @generated from protobuf field: string name = 1 + */ + name: string; +} +/** + * HelloResponse 是 SayHello 的响应消息 + * + * @generated from protobuf message grpcweb.example.v1.HelloResponse + */ +export interface HelloResponse { + /** + * 问候消息 + * + * @generated from protobuf field: string message = 1 + */ + message: string; + /** + * 服务器时间戳 + * + * @generated from protobuf field: int64 timestamp = 2 + */ + timestamp: bigint; +} +/** + * GoodbyeRequest 是 SayGoodbye 的请求消息 + * + * @generated from protobuf message grpcweb.example.v1.GoodbyeRequest + */ +export interface GoodbyeRequest { + /** + * 用户名称 + * + * @generated from protobuf field: string name = 1 + */ + name: string; +} +/** + * GoodbyeResponse 是 SayGoodbye 的响应消息 + * + * @generated from protobuf message grpcweb.example.v1.GoodbyeResponse + */ +export interface GoodbyeResponse { + /** + * 告别消息 + * + * @generated from protobuf field: string message = 1 + */ + message: string; + /** + * 服务器时间戳 + * + * @generated from protobuf field: int64 timestamp = 2 + */ + timestamp: bigint; +} +// @generated message type with reflection information, may provide speed optimized methods +class HelloRequest$Type extends MessageType { + constructor() { + super("grpcweb.example.v1.HelloRequest", [ + { no: 1, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): HelloRequest { + const message = globalThis.Object.create((this.messagePrototype!)); + message.name = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: HelloRequest): HelloRequest { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string name */ 1: + message.name = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: HelloRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string name = 1; */ + if (message.name !== "") + writer.tag(1, WireType.LengthDelimited).string(message.name); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message grpcweb.example.v1.HelloRequest + */ +export const HelloRequest = new HelloRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class HelloResponse$Type extends MessageType { + constructor() { + super("grpcweb.example.v1.HelloResponse", [ + { no: 1, name: "message", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "timestamp", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ } + ]); + } + create(value?: PartialMessage): HelloResponse { + const message = globalThis.Object.create((this.messagePrototype!)); + message.message = ""; + message.timestamp = 0n; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: HelloResponse): HelloResponse { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string message */ 1: + message.message = reader.string(); + break; + case /* int64 timestamp */ 2: + message.timestamp = reader.int64().toBigInt(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: HelloResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string message = 1; */ + if (message.message !== "") + writer.tag(1, WireType.LengthDelimited).string(message.message); + /* int64 timestamp = 2; */ + if (message.timestamp !== 0n) + writer.tag(2, WireType.Varint).int64(message.timestamp); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message grpcweb.example.v1.HelloResponse + */ +export const HelloResponse = new HelloResponse$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GoodbyeRequest$Type extends MessageType { + constructor() { + super("grpcweb.example.v1.GoodbyeRequest", [ + { no: 1, name: "name", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): GoodbyeRequest { + const message = globalThis.Object.create((this.messagePrototype!)); + message.name = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GoodbyeRequest): GoodbyeRequest { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string name */ 1: + message.name = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GoodbyeRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string name = 1; */ + if (message.name !== "") + writer.tag(1, WireType.LengthDelimited).string(message.name); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message grpcweb.example.v1.GoodbyeRequest + */ +export const GoodbyeRequest = new GoodbyeRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GoodbyeResponse$Type extends MessageType { + constructor() { + super("grpcweb.example.v1.GoodbyeResponse", [ + { no: 1, name: "message", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "timestamp", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ } + ]); + } + create(value?: PartialMessage): GoodbyeResponse { + const message = globalThis.Object.create((this.messagePrototype!)); + message.message = ""; + message.timestamp = 0n; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GoodbyeResponse): GoodbyeResponse { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string message */ 1: + message.message = reader.string(); + break; + case /* int64 timestamp */ 2: + message.timestamp = reader.int64().toBigInt(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GoodbyeResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string message = 1; */ + if (message.message !== "") + writer.tag(1, WireType.LengthDelimited).string(message.message); + /* int64 timestamp = 2; */ + if (message.timestamp !== 0n) + writer.tag(2, WireType.Varint).int64(message.timestamp); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message grpcweb.example.v1.GoodbyeResponse + */ +export const GoodbyeResponse = new GoodbyeResponse$Type(); +/** + * @generated ServiceType for protobuf service grpcweb.example.v1.GreeterService + */ +export const GreeterService = new ServiceType("grpcweb.example.v1.GreeterService", [ + { name: "SayHello", options: { "google.api.http": { post: "/v1/greeter/hello", body: "*" } }, I: HelloRequest, O: HelloResponse }, + { name: "SayGoodbye", options: { "google.api.http": { post: "/v1/greeter/goodbye", body: "*" } }, I: GoodbyeRequest, O: GoodbyeResponse } +]); diff --git a/internal/examples/grpcweb/frontend/src/main.ts b/internal/examples/grpcweb/frontend/src/main.ts new file mode 100644 index 000000000..3a011735f --- /dev/null +++ b/internal/examples/grpcweb/frontend/src/main.ts @@ -0,0 +1,152 @@ +import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'; +import { GreeterServiceClient } from './generated/greeter.client'; + +// 创建 gRPC Web Transport +const transport = new GrpcWebFetchTransport({ + baseUrl: window.location.origin, + format: 'binary', // 使用二进制格式 (application/grpc-web+proto) +}); + +// 创建 gRPC 客户端 +const client = new GreeterServiceClient(transport); + +// DOM 元素 +const helloNameInput = document.getElementById('helloName') as HTMLInputElement; +const helloJsonBtn = document.getElementById('helloJsonBtn') as HTMLButtonElement; +const helloGrpcBtn = document.getElementById('helloGrpcBtn') as HTMLButtonElement; +const helloResult = document.getElementById('helloResult') as HTMLDivElement; + +const goodbyeNameInput = document.getElementById('goodbyeName') as HTMLInputElement; +const goodbyeJsonBtn = document.getElementById('goodbyeJsonBtn') as HTMLButtonElement; +const goodbyeGrpcBtn = document.getElementById('goodbyeGrpcBtn') as HTMLButtonElement; +const goodbyeResult = document.getElementById('goodbyeResult') as HTMLDivElement; + +const logResult = document.getElementById('logResult') as HTMLDivElement; + +// 日志记录 +const logs: string[] = []; +function log(message: string) { + const timestamp = new Date().toLocaleTimeString(); + logs.push(`[${timestamp}] ${message}`); + if (logs.length > 50) { + logs.shift(); + } + logResult.textContent = logs.join('\n'); + logResult.scrollTop = logResult.scrollHeight; +} + +// 显示结果 +function showResult(element: HTMLDivElement, data: unknown, isError = false) { + element.textContent = typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data); + element.className = 'result ' + (isError ? 'error' : 'success'); +} + +// HTTP/JSON 请求 +async function testHelloJSON() { + const name = helloNameInput.value || 'Anonymous'; + log(`[HTTP/JSON] 发送请求到 /v1/greeter/hello,name=${name}`); + + try { + const response = await fetch('/v1/greeter/hello', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }) + }); + + const data = await response.json(); + log(`[HTTP/JSON] 收到响应: ${JSON.stringify(data)}`); + showResult(helloResult, data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`[HTTP/JSON] 错误: ${message}`); + showResult(helloResult, message, true); + } +} + +async function testGoodbyeJSON() { + const name = goodbyeNameInput.value || 'Anonymous'; + log(`[HTTP/JSON] 发送请求到 /v1/greeter/goodbye,name=${name}`); + + try { + const response = await fetch('/v1/greeter/goodbye', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }) + }); + + const data = await response.json(); + log(`[HTTP/JSON] 收到响应: ${JSON.stringify(data)}`); + showResult(goodbyeResult, data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`[HTTP/JSON] 错误: ${message}`); + showResult(goodbyeResult, message, true); + } +} + +// gRPC Web 请求 (使用 protobuf-ts) +async function testHelloGRPC() { + const name = helloNameInput.value || 'Anonymous'; + log(`[gRPC Web] 调用 GreeterService.SayHello,name=${name}`); + + try { + const call = client.sayHello({ name }); + + // 等待响应 + const response = await call.response; + + log(`[gRPC Web] 收到响应: message=${response.message}, timestamp=${response.timestamp}`); + showResult(helloResult, { + message: response.message, + timestamp: response.timestamp.toString() + }); + + // 显示状态 + const status = await call.status; + log(`[gRPC Web] 状态: ${status.code}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`[gRPC Web] 错误: ${message}`); + showResult(helloResult, message, true); + } +} + +async function testGoodbyeGRPC() { + const name = goodbyeNameInput.value || 'Anonymous'; + log(`[gRPC Web] 调用 GreeterService.SayGoodbye,name=${name}`); + + try { + const call = client.sayGoodbye({ name }); + + // 等待响应 + const response = await call.response; + + log(`[gRPC Web] 收到响应: message=${response.message}, timestamp=${response.timestamp}`); + showResult(goodbyeResult, { + message: response.message, + timestamp: response.timestamp.toString() + }); + + // 显示元数据 + const status = await call.status; + log(`[gRPC Web] 状态: ${status.code}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`[gRPC Web] 错误: ${message}`); + showResult(goodbyeResult, message, true); + } +} + +// 绑定事件 +helloJsonBtn.addEventListener('click', testHelloJSON); +helloGrpcBtn.addEventListener('click', testHelloGRPC); +goodbyeJsonBtn.addEventListener('click', testGoodbyeJSON); +goodbyeGrpcBtn.addEventListener('click', testGoodbyeGRPC); + +// 初始化日志 +log('页面加载完成,protobuf-ts gRPC Web 客户端已初始化'); +log(`Transport: GrpcWebFetchTransport, baseUrl: ${window.location.origin}`); diff --git a/internal/examples/grpcweb/frontend/tsconfig.json b/internal/examples/grpcweb/frontend/tsconfig.json new file mode 100644 index 000000000..d9b727404 --- /dev/null +++ b/internal/examples/grpcweb/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*", "generated/**/*"], + "exclude": ["node_modules"] +} diff --git a/internal/examples/grpcweb/frontend/vite.config.ts b/internal/examples/grpcweb/frontend/vite.config.ts new file mode 100644 index 000000000..fc19068ee --- /dev/null +++ b/internal/examples/grpcweb/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + root: '.', + build: { + outDir: 'dist', + }, + server: { + port: 3000, + proxy: { + // Proxy gRPC Web requests to the backend + '/grpcweb.example.v1.GreeterService': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/v1': { + target: 'http://localhost:8080', + changeOrigin: true, + } + } + } +}) diff --git a/internal/examples/grpcweb/main.go b/internal/examples/grpcweb/main.go new file mode 100644 index 000000000..6f02cd13d --- /dev/null +++ b/internal/examples/grpcweb/main.go @@ -0,0 +1,114 @@ +// Package main 提供 gRPC Web 示例服务 +// +// 本示例用于验证和测试 gateway 的 gRPC Web 实现 +// +// 运行方式: +// +// go run ./internal/examples/grpcweb +// +// 测试方式: +// +// 1. 使用 curl 测试普通 HTTP/JSON: +// curl -X POST http://localhost:8080/v1/greeter/hello -H "Content-Type: application/json" -d '{"name":"World"}' +// +// 2. 使用浏览器打开 http://localhost:8080/ 测试 gRPC Web +// +// 3. 使用 curl 测试 gRPC Web: +// curl -X POST http://localhost:8080/grpcweb.example.v1.GreeterService/SayHello \ +// -H "Content-Type: application/grpc-web+proto" \ +// -d $'\x00\x00\x00\x00\x07\x0a\x05World' --output - +package main + +import ( + "context" + "embed" + "io/fs" + "log" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/cors" + "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/gofiber/fiber/v3/middleware/static" + + greeterpb "github.com/pubgo/lava/v2/internal/examples/grpcweb/proto" + "github.com/pubgo/lava/v2/pkg/gateway" +) + +//go:embed static/* +var staticFiles embed.FS + +// greeterService 实现 GreeterServiceServer 接口 +type greeterService struct { + greeterpb.UnimplementedGreeterServiceServer +} + +// SayHello 实现问候方法 +func (s *greeterService) SayHello(ctx context.Context, req *greeterpb.HelloRequest) (*greeterpb.HelloResponse, error) { + name := req.GetName() + if name == "" { + name = "Anonymous" + } + return &greeterpb.HelloResponse{ + Message: "Hello, " + name + "!", + Timestamp: time.Now().Unix(), + }, nil +} + +// SayGoodbye 实现告别方法 +func (s *greeterService) SayGoodbye(ctx context.Context, req *greeterpb.GoodbyeRequest) (*greeterpb.GoodbyeResponse, error) { + name := req.GetName() + if name == "" { + name = "Anonymous" + } + return &greeterpb.GoodbyeResponse{ + Message: "Goodbye, " + name + "! See you next time.", + Timestamp: time.Now().Unix(), + }, nil +} + +func main() { + // 创建 Gateway Mux + mux := gateway.NewMux() + + // 注册服务 + mux.RegisterService(&greeterpb.GreeterService_ServiceDesc, &greeterService{}) + + // 创建 Fiber 应用 + app := fiber.New(fiber.Config{ + AppName: "gRPC Web Example", + }) + + // 添加中间件 + app.Use(logger.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "OPTIONS"}, + AllowHeaders: []string{"Content-Type", "X-Grpc-Web", "X-User-Agent"}, + ExposeHeaders: []string{"Grpc-Status", "Grpc-Message"}, + })) + + // 静态文件服务 + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + log.Fatal(err) + } + app.Use("/", static.New("", static.Config{ + FS: staticFS, + Browse: true, + })) + + // 注册 Gateway Handler + app.All("/v1/*", mux.Handler) + // 注册 gRPC Web 路由 (支持直接使用 gRPC 方法路径) + app.Post("/grpcweb.example.v1.GreeterService/*", mux.Handler) + + log.Println("Starting gRPC Web Example Server on :8080") + log.Println("Open http://localhost:8080/ in your browser to test gRPC Web") + log.Println("Test with curl:") + log.Println(" HTTP/JSON: curl -X POST http://localhost:8080/v1/greeter/hello -H 'Content-Type: application/json' -d '{\"name\":\"World\"}'") + + if err := app.Listen(":8080"); err != nil { + log.Fatal(err) + } +} diff --git a/internal/examples/grpcweb/proto/greeter.pb.go b/internal/examples/grpcweb/proto/greeter.pb.go new file mode 100644 index 000000000..bd00f1bea --- /dev/null +++ b/internal/examples/grpcweb/proto/greeter.pb.go @@ -0,0 +1,328 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.35.2 +// protoc v5.29.3 +// source: greeter.proto + +package greeterpb + +import ( + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// HelloRequest 是 SayHello 的请求消息 +type HelloRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // 用户名称 + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *HelloRequest) Reset() { + *x = HelloRequest{} + mi := &file_greeter_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloRequest) ProtoMessage() {} + +func (x *HelloRequest) ProtoReflect() protoreflect.Message { + mi := &file_greeter_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloRequest.ProtoReflect.Descriptor instead. +func (*HelloRequest) Descriptor() ([]byte, []int) { + return file_greeter_proto_rawDescGZIP(), []int{0} +} + +func (x *HelloRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// HelloResponse 是 SayHello 的响应消息 +type HelloResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // 问候消息 + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + // 服务器时间戳 + Timestamp int64 `protobuf:"varint,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *HelloResponse) Reset() { + *x = HelloResponse{} + mi := &file_greeter_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloResponse) ProtoMessage() {} + +func (x *HelloResponse) ProtoReflect() protoreflect.Message { + mi := &file_greeter_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloResponse.ProtoReflect.Descriptor instead. +func (*HelloResponse) Descriptor() ([]byte, []int) { + return file_greeter_proto_rawDescGZIP(), []int{1} +} + +func (x *HelloResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *HelloResponse) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +// GoodbyeRequest 是 SayGoodbye 的请求消息 +type GoodbyeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // 用户名称 + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *GoodbyeRequest) Reset() { + *x = GoodbyeRequest{} + mi := &file_greeter_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GoodbyeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GoodbyeRequest) ProtoMessage() {} + +func (x *GoodbyeRequest) ProtoReflect() protoreflect.Message { + mi := &file_greeter_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GoodbyeRequest.ProtoReflect.Descriptor instead. +func (*GoodbyeRequest) Descriptor() ([]byte, []int) { + return file_greeter_proto_rawDescGZIP(), []int{2} +} + +func (x *GoodbyeRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// GoodbyeResponse 是 SayGoodbye 的响应消息 +type GoodbyeResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // 告别消息 + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + // 服务器时间戳 + Timestamp int64 `protobuf:"varint,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *GoodbyeResponse) Reset() { + *x = GoodbyeResponse{} + mi := &file_greeter_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GoodbyeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GoodbyeResponse) ProtoMessage() {} + +func (x *GoodbyeResponse) ProtoReflect() protoreflect.Message { + mi := &file_greeter_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GoodbyeResponse.ProtoReflect.Descriptor instead. +func (*GoodbyeResponse) Descriptor() ([]byte, []int) { + return file_greeter_proto_rawDescGZIP(), []int{3} +} + +func (x *GoodbyeResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *GoodbyeResponse) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +var File_greeter_proto protoreflect.FileDescriptor + +var file_greeter_proto_rawDesc = []byte{ + 0x0a, 0x0d, 0x67, 0x72, 0x65, 0x65, 0x74, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x12, 0x67, 0x72, 0x70, 0x63, 0x77, 0x65, 0x62, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x2e, 0x76, 0x31, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x22, 0x22, 0x0a, 0x0c, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x47, 0x0a, 0x0d, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x24, + 0x0a, 0x0e, 0x47, 0x6f, 0x6f, 0x64, 0x62, 0x79, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x49, 0x0a, 0x0f, 0x47, 0x6f, 0x6f, 0x64, 0x62, 0x79, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x32, + 0xf6, 0x01, 0x0a, 0x0e, 0x47, 0x72, 0x65, 0x65, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x6d, 0x0a, 0x08, 0x53, 0x61, 0x79, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x20, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x77, 0x65, 0x62, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x21, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x77, 0x65, 0x62, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, + 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x1c, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x16, 0x3a, 0x01, 0x2a, 0x22, 0x11, + 0x2f, 0x76, 0x31, 0x2f, 0x67, 0x72, 0x65, 0x65, 0x74, 0x65, 0x72, 0x2f, 0x68, 0x65, 0x6c, 0x6c, + 0x6f, 0x12, 0x75, 0x0a, 0x0a, 0x53, 0x61, 0x79, 0x47, 0x6f, 0x6f, 0x64, 0x62, 0x79, 0x65, 0x12, + 0x22, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x77, 0x65, 0x62, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x6f, 0x6f, 0x64, 0x62, 0x79, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x77, 0x65, 0x62, 0x2e, 0x65, 0x78, + 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x6f, 0x6f, 0x64, 0x62, 0x79, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x18, + 0x3a, 0x01, 0x2a, 0x22, 0x13, 0x2f, 0x76, 0x31, 0x2f, 0x67, 0x72, 0x65, 0x65, 0x74, 0x65, 0x72, + 0x2f, 0x67, 0x6f, 0x6f, 0x64, 0x62, 0x79, 0x65, 0x42, 0x44, 0x5a, 0x42, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x75, 0x62, 0x67, 0x6f, 0x2f, 0x6c, 0x61, 0x76, + 0x61, 0x2f, 0x76, 0x32, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x65, 0x78, + 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x77, 0x65, 0x62, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x67, 0x72, 0x65, 0x65, 0x74, 0x65, 0x72, 0x70, 0x62, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_greeter_proto_rawDescOnce sync.Once + file_greeter_proto_rawDescData = file_greeter_proto_rawDesc +) + +func file_greeter_proto_rawDescGZIP() []byte { + file_greeter_proto_rawDescOnce.Do(func() { + file_greeter_proto_rawDescData = protoimpl.X.CompressGZIP(file_greeter_proto_rawDescData) + }) + return file_greeter_proto_rawDescData +} + +var file_greeter_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_greeter_proto_goTypes = []any{ + (*HelloRequest)(nil), // 0: grpcweb.example.v1.HelloRequest + (*HelloResponse)(nil), // 1: grpcweb.example.v1.HelloResponse + (*GoodbyeRequest)(nil), // 2: grpcweb.example.v1.GoodbyeRequest + (*GoodbyeResponse)(nil), // 3: grpcweb.example.v1.GoodbyeResponse +} +var file_greeter_proto_depIdxs = []int32{ + 0, // 0: grpcweb.example.v1.GreeterService.SayHello:input_type -> grpcweb.example.v1.HelloRequest + 2, // 1: grpcweb.example.v1.GreeterService.SayGoodbye:input_type -> grpcweb.example.v1.GoodbyeRequest + 1, // 2: grpcweb.example.v1.GreeterService.SayHello:output_type -> grpcweb.example.v1.HelloResponse + 3, // 3: grpcweb.example.v1.GreeterService.SayGoodbye:output_type -> grpcweb.example.v1.GoodbyeResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_greeter_proto_init() } +func file_greeter_proto_init() { + if File_greeter_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_greeter_proto_rawDesc, + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_greeter_proto_goTypes, + DependencyIndexes: file_greeter_proto_depIdxs, + MessageInfos: file_greeter_proto_msgTypes, + }.Build() + File_greeter_proto = out.File + file_greeter_proto_rawDesc = nil + file_greeter_proto_goTypes = nil + file_greeter_proto_depIdxs = nil +} diff --git a/internal/examples/grpcweb/proto/greeter.proto b/internal/examples/grpcweb/proto/greeter.proto new file mode 100644 index 000000000..58c10890c --- /dev/null +++ b/internal/examples/grpcweb/proto/greeter.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +package grpcweb.example.v1; + +option go_package = "github.com/pubgo/lava/v2/internal/examples/grpcweb/proto;greeterpb"; + +import "google/api/annotations.proto"; + +// GreeterService 提供简单的问候服务,用于测试 gRPC Web 实现 +service GreeterService { + // SayHello 返回问候语 + rpc SayHello(HelloRequest) returns (HelloResponse) { + option (google.api.http) = { + post: "/v1/greeter/hello" + body: "*" + }; + } + + // SayGoodbye 返回告别语 + rpc SayGoodbye(GoodbyeRequest) returns (GoodbyeResponse) { + option (google.api.http) = { + post: "/v1/greeter/goodbye" + body: "*" + }; + } +} + +// HelloRequest 是 SayHello 的请求消息 +message HelloRequest { + // 用户名称 + string name = 1; +} + +// HelloResponse 是 SayHello 的响应消息 +message HelloResponse { + // 问候消息 + string message = 1; + // 服务器时间戳 + int64 timestamp = 2; +} + +// GoodbyeRequest 是 SayGoodbye 的请求消息 +message GoodbyeRequest { + // 用户名称 + string name = 1; +} + +// GoodbyeResponse 是 SayGoodbye 的响应消息 +message GoodbyeResponse { + // 告别消息 + string message = 1; + // 服务器时间戳 + int64 timestamp = 2; +} diff --git a/internal/examples/grpcweb/proto/greeter_grpc.pb.go b/internal/examples/grpcweb/proto/greeter_grpc.pb.go new file mode 100644 index 000000000..4d3ff3403 --- /dev/null +++ b/internal/examples/grpcweb/proto/greeter_grpc.pb.go @@ -0,0 +1,167 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.3 +// source: greeter.proto + +package greeterpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GreeterService_SayHello_FullMethodName = "/grpcweb.example.v1.GreeterService/SayHello" + GreeterService_SayGoodbye_FullMethodName = "/grpcweb.example.v1.GreeterService/SayGoodbye" +) + +// GreeterServiceClient is the client API for GreeterService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// GreeterService 提供简单的问候服务,用于测试 gRPC Web 实现 +type GreeterServiceClient interface { + // SayHello 返回问候语 + SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error) + // SayGoodbye 返回告别语 + SayGoodbye(ctx context.Context, in *GoodbyeRequest, opts ...grpc.CallOption) (*GoodbyeResponse, error) +} + +type greeterServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGreeterServiceClient(cc grpc.ClientConnInterface) GreeterServiceClient { + return &greeterServiceClient{cc} +} + +func (c *greeterServiceClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HelloResponse) + err := c.cc.Invoke(ctx, GreeterService_SayHello_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *greeterServiceClient) SayGoodbye(ctx context.Context, in *GoodbyeRequest, opts ...grpc.CallOption) (*GoodbyeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GoodbyeResponse) + err := c.cc.Invoke(ctx, GreeterService_SayGoodbye_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// GreeterServiceServer is the server API for GreeterService service. +// All implementations must embed UnimplementedGreeterServiceServer +// for forward compatibility. +// +// GreeterService 提供简单的问候服务,用于测试 gRPC Web 实现 +type GreeterServiceServer interface { + // SayHello 返回问候语 + SayHello(context.Context, *HelloRequest) (*HelloResponse, error) + // SayGoodbye 返回告别语 + SayGoodbye(context.Context, *GoodbyeRequest) (*GoodbyeResponse, error) + mustEmbedUnimplementedGreeterServiceServer() +} + +// UnimplementedGreeterServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGreeterServiceServer struct{} + +func (UnimplementedGreeterServiceServer) SayHello(context.Context, *HelloRequest) (*HelloResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented") +} +func (UnimplementedGreeterServiceServer) SayGoodbye(context.Context, *GoodbyeRequest) (*GoodbyeResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SayGoodbye not implemented") +} +func (UnimplementedGreeterServiceServer) mustEmbedUnimplementedGreeterServiceServer() {} +func (UnimplementedGreeterServiceServer) testEmbeddedByValue() {} + +// UnsafeGreeterServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GreeterServiceServer will +// result in compilation errors. +type UnsafeGreeterServiceServer interface { + mustEmbedUnimplementedGreeterServiceServer() +} + +func RegisterGreeterServiceServer(s grpc.ServiceRegistrar, srv GreeterServiceServer) { + // If the following call pancis, it indicates UnimplementedGreeterServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GreeterService_ServiceDesc, srv) +} + +func _GreeterService_SayHello_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HelloRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GreeterServiceServer).SayHello(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GreeterService_SayHello_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GreeterServiceServer).SayHello(ctx, req.(*HelloRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GreeterService_SayGoodbye_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GoodbyeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GreeterServiceServer).SayGoodbye(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GreeterService_SayGoodbye_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GreeterServiceServer).SayGoodbye(ctx, req.(*GoodbyeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// GreeterService_ServiceDesc is the grpc.ServiceDesc for GreeterService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GreeterService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "grpcweb.example.v1.GreeterService", + HandlerType: (*GreeterServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SayHello", + Handler: _GreeterService_SayHello_Handler, + }, + { + MethodName: "SayGoodbye", + Handler: _GreeterService_SayGoodbye_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "greeter.proto", +} diff --git a/internal/examples/grpcweb/static/assets/index-4ouSPqqH.js b/internal/examples/grpcweb/static/assets/index-4ouSPqqH.js new file mode 100644 index 000000000..0d3e2da37 --- /dev/null +++ b/internal/examples/grpcweb/static/assets/index-4ouSPqqH.js @@ -0,0 +1,5 @@ +var je=Object.defineProperty;var Ce=(r,e,t)=>e in r?je(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t;var W=(r,e,t)=>Ce(r,typeof e!="symbol"?e+"":e,t);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function t(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=t(s);fetch(s.href,i)}})();function Ae(r){let e=typeof r;if(e=="object"){if(Array.isArray(r))return"array";if(r===null)return"null"}return e}function Ke(r){return r!==null&&typeof r=="object"&&!Array.isArray(r)}let $="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split(""),re=[];for(let r=0;r<$.length;r++)re[$[r].charCodeAt(0)]=r;re[45]=$.indexOf("+");re[95]=$.indexOf("/");function De(r){let e=r.length*3/4;r[r.length-2]=="="?e-=2:r[r.length-1]=="="&&(e-=1);let t=new Uint8Array(e),n=0,s=0,i,o=0;for(let a=0;a>4,o=i,s=2;break;case 2:t[n++]=(o&15)<<4|(i&60)>>2,o=i,s=3;break;case 3:t[n++]=(o&3)<<6|i,s=0;break}}if(s==1)throw Error("invalid base64 string.");return t.subarray(0,n)}function Re(r){let e="",t=0,n,s=0;for(let i=0;i>2],s=(n&3)<<4,t=1;break;case 1:e+=$[s|n>>4],s=(n&15)<<2,t=2;break;case 2:e+=$[s|n>>6],e+=$[n&63],t=0;break}return t&&(e+=$[s],e+="=",t==1&&(e+="=")),e}var F;(function(r){r.symbol=Symbol.for("protobuf-ts/unknown"),r.onRead=(t,n,s,i,o)=>{(e(n)?n[r.symbol]:n[r.symbol]=[]).push({no:s,wireType:i,data:o})},r.onWrite=(t,n,s)=>{for(let{no:i,wireType:o,data:a}of r.list(n))s.tag(i,o).raw(a)},r.list=(t,n)=>{if(e(t)){let s=t[r.symbol];return n?s.filter(i=>i.no==n):s}return[]},r.last=(t,n)=>r.list(t,n).slice(-1)[0];const e=t=>t&&Array.isArray(t[r.symbol])})(F||(F={}));function Xe(r,e){return Object.assign(Object.assign({},r),e)}var y;(function(r){r[r.Varint=0]="Varint",r[r.Bit64=1]="Bit64",r[r.LengthDelimited=2]="LengthDelimited",r[r.StartGroup=3]="StartGroup",r[r.EndGroup=4]="EndGroup",r[r.Bit32=5]="Bit32"})(y||(y={}));function Je(){let r=0,e=0;for(let n=0;n<28;n+=7){let s=this.buf[this.pos++];if(r|=(s&127)<>4,(t&128)==0)return this.assertBounds(),[r,e];for(let n=3;n<=31;n+=7){let s=this.buf[this.pos++];if(e|=(s&127)<>>i,a=!(!(o>>>7)&&e==0),l=(a?o|128:o)&255;if(t.push(l),!a)return}const n=r>>>28&15|(e&7)<<4,s=e>>3!=0;if(t.push((s?n|128:n)&255),!!s){for(let i=3;i<31;i=i+7){const o=e>>>i,a=!!(o>>>7),l=(a?o|128:o)&255;if(t.push(l),!a)return}t.push(e>>>31&1)}}const Y=65536*65536;function Be(r){let e=r[0]=="-";e&&(r=r.slice(1));const t=1e6;let n=0,s=0;function i(o,a){const l=Number(r.slice(o,a));s*=t,n=n*t+l,n>=Y&&(s=s+(n/Y|0),n=n%Y)}return i(-24,-18),i(-18,-12),i(-12,-6),i(-6),[e,n,s]}function le(r,e){if(e>>>0<=2097151)return""+(Y*e+(r>>>0));let t=r&16777215,n=(r>>>24|e<<8)>>>0&16777215,s=e>>16&65535,i=t+n*6777216+s*6710656,o=n+s*8147497,a=s*2,l=1e7;i>=l&&(o+=Math.floor(i/l),i%=l),o>=l&&(a+=Math.floor(o/l),o%=l);function c(h,m){let g=h?String(h):"";return m?"0000000".slice(g.length)+g:g}return c(a,0)+c(o,a)+c(i,1)}function me(r,e){if(r>=0){for(;r>127;)e.push(r&127|128),r=r>>>7;e.push(r)}else{for(let t=0;t<9;t++)e.push(r&127|128),r=r>>7;e.push(1)}}function We(){let r=this.buf[this.pos++],e=r&127;if((r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<7,(r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<14,(r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<21,(r&128)==0)return this.assertBounds(),e;r=this.buf[this.pos++],e|=(r&15)<<28;for(let t=5;(r&128)!==0&&t<10;t++)r=this.buf[this.pos++];if((r&128)!=0)throw new Error("invalid varint");return this.assertBounds(),e>>>0}let w;function Ge(){const r=new DataView(new ArrayBuffer(8));w=globalThis.BigInt!==void 0&&typeof r.getBigInt64=="function"&&typeof r.getBigUint64=="function"&&typeof r.setBigInt64=="function"&&typeof r.setBigUint64=="function"?{MIN:BigInt("-9223372036854775808"),MAX:BigInt("9223372036854775807"),UMIN:BigInt("0"),UMAX:BigInt("18446744073709551615"),C:BigInt,V:r}:void 0}Ge();function Le(r){if(!r)throw new Error("BigInt unavailable, see https://github.com/timostamm/protobuf-ts/blob/v1.0.8/MANUAL.md#bigint-support")}const Fe=/^-?[0-9]+$/,z=4294967296,G=2147483648;class _e{constructor(e,t){this.lo=e|0,this.hi=t|0}isZero(){return this.lo==0&&this.hi==0}toNumber(){let e=this.hi*z+(this.lo>>>0);if(!Number.isSafeInteger(e))throw new Error("cannot convert to safe number");return e}}class D extends _e{static from(e){if(w)switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=="")throw new Error("string is no integer");e=w.C(e);case"number":if(e===0)return this.ZERO;e=w.C(e);case"bigint":if(!e)return this.ZERO;if(ew.UMAX)throw new Error("ulong too large");return w.V.setBigUint64(0,e,!0),new D(w.V.getInt32(0,!0),w.V.getInt32(4,!0))}else switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=e.trim(),!Fe.test(e))throw new Error("string is no integer");let[t,n,s]=Be(e);if(t)throw new Error("signed value for ulong");return new D(n,s);case"number":if(e==0)return this.ZERO;if(!Number.isSafeInteger(e))throw new Error("number is no integer");if(e<0)throw new Error("signed value for ulong");return new D(e,e/z)}throw new Error("unknown value "+typeof e)}toString(){return w?this.toBigInt().toString():le(this.lo,this.hi)}toBigInt(){return Le(w),w.V.setInt32(0,this.lo,!0),w.V.setInt32(4,this.hi,!0),w.V.getBigUint64(0,!0)}}D.ZERO=new D(0,0);class E extends _e{static from(e){if(w)switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=="")throw new Error("string is no integer");e=w.C(e);case"number":if(e===0)return this.ZERO;e=w.C(e);case"bigint":if(!e)return this.ZERO;if(ew.MAX)throw new Error("signed long too large");return w.V.setBigInt64(0,e,!0),new E(w.V.getInt32(0,!0),w.V.getInt32(4,!0))}else switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=e.trim(),!Fe.test(e))throw new Error("string is no integer");let[t,n,s]=Be(e);if(t){if(s>G||s==G&&n!=0)throw new Error("signed long too small")}else if(s>=G)throw new Error("signed long too large");let i=new E(n,s);return t?i.negate():i;case"number":if(e==0)return this.ZERO;if(!Number.isSafeInteger(e))throw new Error("number is no integer");return e>0?new E(e,e/z):new E(-e,-e/z).negate()}throw new Error("unknown value "+typeof e)}isNegative(){return(this.hi&G)!==0}negate(){let e=~this.hi,t=this.lo;return t?t=~t+1:e+=1,new E(t,e)}toString(){if(w)return this.toBigInt().toString();if(this.isNegative()){let e=this.negate();return"-"+le(e.lo,e.hi)}return le(this.lo,this.hi)}toBigInt(){return Le(w),w.V.setInt32(0,this.lo,!0),w.V.setInt32(4,this.hi,!0),w.V.getBigInt64(0,!0)}}E.ZERO=new E(0,0);const de={readUnknownField:!0,readerFactory:r=>new He(r)};function qe(r){return r?Object.assign(Object.assign({},de),r):de}class He{constructor(e,t){this.varint64=Je,this.uint32=We,this.buf=e,this.len=e.length,this.pos=0,this.view=new DataView(e.buffer,e.byteOffset,e.byteLength),this.textDecoder=t??new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0})}tag(){let e=this.uint32(),t=e>>>3,n=e&7;if(t<=0||n<0||n>5)throw new Error("illegal tag: field no "+t+" wire type "+n);return[t,n]}skip(e){let t=this.pos;switch(e){case y.Varint:for(;this.buf[this.pos++]&128;);break;case y.Bit64:this.pos+=4;case y.Bit32:this.pos+=4;break;case y.LengthDelimited:let n=this.uint32();this.pos+=n;break;case y.StartGroup:let s;for(;(s=this.tag()[1])!==y.EndGroup;)this.skip(s);break;default:throw new Error("cant skip wire type "+e)}return this.assertBounds(),this.buf.subarray(t,this.pos)}assertBounds(){if(this.pos>this.len)throw new RangeError("premature EOF")}int32(){return this.uint32()|0}sint32(){let e=this.uint32();return e>>>1^-(e&1)}int64(){return new E(...this.varint64())}uint64(){return new D(...this.varint64())}sint64(){let[e,t]=this.varint64(),n=-(e&1);return e=(e>>>1|(t&1)<<31)^n,t=t>>>1^n,new E(e,t)}bool(){let[e,t]=this.varint64();return e!==0||t!==0}fixed32(){return this.view.getUint32((this.pos+=4)-4,!0)}sfixed32(){return this.view.getInt32((this.pos+=4)-4,!0)}fixed64(){return new D(this.sfixed32(),this.sfixed32())}sfixed64(){return new E(this.sfixed32(),this.sfixed32())}float(){return this.view.getFloat32((this.pos+=4)-4,!0)}double(){return this.view.getFloat64((this.pos+=8)-8,!0)}bytes(){let e=this.uint32(),t=this.pos;return this.pos+=e,this.assertBounds(),this.buf.subarray(t,t+e)}string(){return this.textDecoder.decode(this.bytes())}}function p(r,e){if(!r)throw new Error(e)}const Ze=34028234663852886e22,Ye=-34028234663852886e22,ze=4294967295,Qe=2147483647,et=-2147483648;function J(r){if(typeof r!="number")throw new Error("invalid int 32: "+typeof r);if(!Number.isInteger(r)||r>Qe||rze||r<0)throw new Error("invalid uint 32: "+r)}function he(r){if(typeof r!="number")throw new Error("invalid float 32: "+typeof r);if(Number.isFinite(r)&&(r>Ze||rnew nt};function tt(r){return r?Object.assign(Object.assign({},pe),r):pe}class nt{constructor(e){this.stack=[],this.textEncoder=e??new TextEncoder,this.chunks=[],this.buf=[]}finish(){this.chunks.push(new Uint8Array(this.buf));let e=0;for(let s=0;s>>0)}raw(e){return this.buf.length&&(this.chunks.push(new Uint8Array(this.buf)),this.buf=[]),this.chunks.push(e),this}uint32(e){for(Q(e);e>127;)this.buf.push(e&127|128),e=e>>>7;return this.buf.push(e),this}int32(e){return J(e),me(e,this.buf),this}bool(e){return this.buf.push(e?1:0),this}bytes(e){return this.uint32(e.byteLength),this.raw(e)}string(e){let t=this.textEncoder.encode(e);return this.uint32(t.byteLength),this.raw(t)}float(e){he(e);let t=new Uint8Array(4);return new DataView(t.buffer).setFloat32(0,e,!0),this.raw(t)}double(e){let t=new Uint8Array(8);return new DataView(t.buffer).setFloat64(0,e,!0),this.raw(t)}fixed32(e){Q(e);let t=new Uint8Array(4);return new DataView(t.buffer).setUint32(0,e,!0),this.raw(t)}sfixed32(e){J(e);let t=new Uint8Array(4);return new DataView(t.buffer).setInt32(0,e,!0),this.raw(t)}sint32(e){return J(e),e=(e<<1^e>>31)>>>0,me(e,this.buf),this}sfixed64(e){let t=new Uint8Array(8),n=new DataView(t.buffer),s=E.from(e);return n.setInt32(0,s.lo,!0),n.setInt32(4,s.hi,!0),this.raw(t)}fixed64(e){let t=new Uint8Array(8),n=new DataView(t.buffer),s=D.from(e);return n.setInt32(0,s.lo,!0),n.setInt32(4,s.hi,!0),this.raw(t)}int64(e){let t=E.from(e);return ie(t.lo,t.hi,this.buf),this}sint64(e){let t=E.from(e),n=t.hi>>31,s=t.lo<<1^n,i=(t.hi<<1|t.lo>>>31)^n;return ie(s,i,this.buf),this}uint64(e){let t=D.from(e);return ie(t.lo,t.hi,this.buf),this}}const ge={emitDefaultValues:!1,enumAsInteger:!1,useProtoFieldName:!1,prettySpaces:0},be={ignoreUnknownFields:!1};function rt(r){return r?Object.assign(Object.assign({},be),r):be}function st(r){return r?Object.assign(Object.assign({},ge),r):ge}function it(r,e){var t,n;let s=Object.assign(Object.assign({},r),e);return s.typeRegistry=[...(t=r==null?void 0:r.typeRegistry)!==null&&t!==void 0?t:[],...(n=e==null?void 0:e.typeRegistry)!==null&&n!==void 0?n:[]],s}const Ue=Symbol.for("protobuf-ts/message-type");function ce(r){let e=!1;const t=[];for(let n=0;n!s.includes(o))||!n&&s.some(o=>!i.known.includes(o)))return!1;if(t<1)return!0;for(const o of i.oneofs){const a=e[o];if(!at(a))return!1;if(a.oneofKind===void 0)continue;const l=this.fields.find(c=>c.localName===a.oneofKind);if(!l||!this.field(a[a.oneofKind],l,n,t))return!1}for(const o of this.fields)if(o.oneof===void 0&&!this.field(e[o.localName],o,n,t))return!1;return!0}field(e,t,n,s){let i=t.repeat;switch(t.kind){case"scalar":return e===void 0?t.opt:i?this.scalars(e,t.T,s,t.L):this.scalar(e,t.T,t.L);case"enum":return e===void 0?t.opt:i?this.scalars(e,f.INT32,s):this.scalar(e,f.INT32);case"message":return e===void 0?!0:i?this.messages(e,t.T(),n,s):this.message(e,t.T(),n,s);case"map":if(typeof e!="object"||e===null)return!1;if(s<2)return!0;if(!this.mapKeys(e,t.K,s))return!1;switch(t.V.kind){case"scalar":return this.scalars(Object.values(e),t.V.T,s,t.V.L);case"enum":return this.scalars(Object.values(e),f.INT32,s);case"message":return this.messages(Object.values(e),t.V.T(),n,s)}break}return!0}message(e,t,n,s){return n?t.isAssignable(e,s):t.is(e,s)}messages(e,t,n,s){if(!Array.isArray(e))return!1;if(s<2)return!0;if(n){for(let i=0;iparseInt(i)),t,n);case f.BOOL:return this.scalars(s.slice(0,n).map(i=>i=="true"?!0:i=="false"?!1:i),t,n);default:return this.scalars(s,t,n,V.STRING)}}}function _(r,e){switch(e){case V.BIGINT:return r.toBigInt();case V.NUMBER:return r.toNumber();default:return r.toString()}}class ct{constructor(e){this.info=e}prepare(){var e;if(this.fMap===void 0){this.fMap={};const t=(e=this.info.fields)!==null&&e!==void 0?e:[];for(const n of t)this.fMap[n.name]=n,this.fMap[n.jsonName]=n,this.fMap[n.localName]=n}}assert(e,t,n){if(!e){let s=Ae(n);throw(s=="number"||s=="boolean")&&(s=n.toString()),new Error(`Cannot parse JSON ${s} for ${this.info.typeName}#${t}`)}}read(e,t,n){this.prepare();const s=[];for(const[i,o]of Object.entries(e)){const a=this.fMap[i];if(!a){if(!n.ignoreUnknownFields)throw new Error(`Found unknown field while reading ${this.info.typeName} from JSON format. JSON key: ${i}`);continue}const l=a.localName;let c;if(a.oneof){if(o===null&&(a.kind!=="enum"||a.T()[0]!=="google.protobuf.NullValue"))continue;if(s.includes(a.oneof))throw new Error(`Multiple members of the oneof group "${a.oneof}" of ${this.info.typeName} are present in JSON.`);s.push(a.oneof),c=t[a.oneof]={oneofKind:l}}else c=t;if(a.kind=="map"){if(o===null)continue;this.assert(Ke(o),a.name,o);const h=c[l];for(const[m,g]of Object.entries(o)){this.assert(g!==null,a.name+" map value",null);let O;switch(a.V.kind){case"message":O=a.V.T().internalJsonRead(g,n);break;case"enum":if(O=this.enum(a.V.T(),g,a.name,n.ignoreUnknownFields),O===!1)continue;break;case"scalar":O=this.scalar(g,a.V.T,a.V.L,a.name);break}this.assert(O!==void 0,a.name+" map value",g);let R=m;a.K==f.BOOL&&(R=R=="true"?!0:R=="false"?!1:R),R=this.scalar(R,a.K,V.STRING,a.name).toString(),h[R]=O}}else if(a.repeat){if(o===null)continue;this.assert(Array.isArray(o),a.name,o);const h=c[l];for(const m of o){this.assert(m!==null,a.name,null);let g;switch(a.kind){case"message":g=a.T().internalJsonRead(m,n);break;case"enum":if(g=this.enum(a.T(),m,a.name,n.ignoreUnknownFields),g===!1)continue;break;case"scalar":g=this.scalar(m,a.T,a.L,a.name);break}this.assert(g!==void 0,a.name,o),h.push(g)}}else switch(a.kind){case"message":if(o===null&&a.T().typeName!="google.protobuf.Value"){this.assert(a.oneof===void 0,a.name+" (oneof member)",null);continue}c[l]=a.T().internalJsonRead(o,n,c[l]);break;case"enum":if(o===null)continue;let h=this.enum(a.T(),o,a.name,n.ignoreUnknownFields);if(h===!1)continue;c[l]=h;break;case"scalar":if(o===null)continue;c[l]=this.scalar(o,a.T,a.L,a.name);break}}}enum(e,t,n,s){if(e[0]=="google.protobuf.NullValue"&&p(t===null||t==="NULL_VALUE",`Unable to parse field ${this.info.typeName}#${n}, enum ${e[0]} only accepts null.`),t===null)return 0;switch(typeof t){case"number":return p(Number.isInteger(t),`Unable to parse field ${this.info.typeName}#${n}, enum can only be integral number, got ${t}.`),t;case"string":let i=t;e[2]&&t.substring(0,e[2].length)===e[2]&&(i=t.substring(e[2].length));let o=e[1][i];return typeof o>"u"&&s?!1:(p(typeof o=="number",`Unable to parse field ${this.info.typeName}#${n}, enum ${e[0]} has no value for "${t}".`),o)}p(!1,`Unable to parse field ${this.info.typeName}#${n}, cannot parse enum value from ${typeof t}".`)}scalar(e,t,n,s){let i;try{switch(t){case f.DOUBLE:case f.FLOAT:if(e===null)return 0;if(e==="NaN")return Number.NaN;if(e==="Infinity")return Number.POSITIVE_INFINITY;if(e==="-Infinity")return Number.NEGATIVE_INFINITY;if(e===""){i="empty string";break}if(typeof e=="string"&&e.trim().length!==e.length){i="extra whitespace";break}if(typeof e!="string"&&typeof e!="number")break;let o=Number(e);if(Number.isNaN(o)){i="not a number";break}if(!Number.isFinite(o)){i="too large or small";break}return t==f.FLOAT&&he(o),o;case f.INT32:case f.FIXED32:case f.SFIXED32:case f.SINT32:case f.UINT32:if(e===null)return 0;let a;if(typeof e=="number"?a=e:e===""?i="empty string":typeof e=="string"&&(e.trim().length!==e.length?i="extra whitespace":a=Number(e)),a===void 0)break;return t==f.UINT32?Q(a):J(a),a;case f.INT64:case f.SFIXED64:case f.SINT64:if(e===null)return _(E.ZERO,n);if(typeof e!="number"&&typeof e!="string")break;return _(E.from(e),n);case f.FIXED64:case f.UINT64:if(e===null)return _(D.ZERO,n);if(typeof e!="number"&&typeof e!="string")break;return _(D.from(e),n);case f.BOOL:if(e===null)return!1;if(typeof e!="boolean")break;return e;case f.STRING:if(e===null)return"";if(typeof e!="string"){i="extra whitespace";break}try{encodeURIComponent(e)}catch(l){l="invalid UTF8";break}return e;case f.BYTES:if(e===null||e==="")return new Uint8Array(0);if(typeof e!="string")break;return De(e)}}catch(o){i=o.message}this.assert(!1,s+(i?" - "+i:""),e)}}class ft{constructor(e){var t;this.fields=(t=e.fields)!==null&&t!==void 0?t:[]}write(e,t){const n={},s=e;for(const i of this.fields){if(!i.oneof){let c=this.field(i,s[i.localName],t);c!==void 0&&(n[t.useProtoFieldName?i.name:i.jsonName]=c);continue}const o=s[i.oneof];if(o.oneofKind!==i.localName)continue;const a=i.kind=="scalar"||i.kind=="enum"?Object.assign(Object.assign({},t),{emitDefaultValues:!0}):t;let l=this.field(i,o[i.localName],a);p(l!==void 0),n[t.useProtoFieldName?i.name:i.jsonName]=l}return n}field(e,t,n){let s;if(e.kind=="map"){p(typeof t=="object"&&t!==null);const i={};switch(e.V.kind){case"scalar":for(const[l,c]of Object.entries(t)){const h=this.scalar(e.V.T,c,e.name,!1,!0);p(h!==void 0),i[l.toString()]=h}break;case"message":const o=e.V.T();for(const[l,c]of Object.entries(t)){const h=this.message(o,c,e.name,n);p(h!==void 0),i[l.toString()]=h}break;case"enum":const a=e.V.T();for(const[l,c]of Object.entries(t)){p(c===void 0||typeof c=="number");const h=this.enum(a,c,e.name,!1,!0,n.enumAsInteger);p(h!==void 0),i[l.toString()]=h}break}(n.emitDefaultValues||Object.keys(i).length>0)&&(s=i)}else if(e.repeat){p(Array.isArray(t));const i=[];switch(e.kind){case"scalar":for(let l=0;l0||n.emitDefaultValues)&&(s=i)}else switch(e.kind){case"scalar":s=this.scalar(e.T,t,e.name,e.opt,n.emitDefaultValues);break;case"enum":s=this.enum(e.T(),t,e.name,e.opt,n.emitDefaultValues,n.enumAsInteger);break;case"message":s=this.message(e.T(),t,e.name,n);break}return s}enum(e,t,n,s,i,o){if(e[0]=="google.protobuf.NullValue")return!i&&!s?void 0:null;if(t===void 0){p(s);return}if(!(t===0&&!i&&!s))return p(typeof t=="number"),p(Number.isInteger(t)),o||!e[1].hasOwnProperty(t)?t:e[2]?e[2]+e[1][t]:e[1][t]}message(e,t,n,s){return t===void 0?s.emitDefaultValues?null:void 0:e.internalJsonWrite(t,s)}scalar(e,t,n,s,i){if(t===void 0){p(s);return}const o=i||s;switch(e){case f.INT32:case f.SFIXED32:case f.SINT32:return t===0?o?0:void 0:(J(t),t);case f.FIXED32:case f.UINT32:return t===0?o?0:void 0:(Q(t),t);case f.FLOAT:he(t);case f.DOUBLE:return t===0?o?0:void 0:(p(typeof t=="number"),Number.isNaN(t)?"NaN":t===Number.POSITIVE_INFINITY?"Infinity":t===Number.NEGATIVE_INFINITY?"-Infinity":t);case f.STRING:return t===""?o?"":void 0:(p(typeof t=="string"),t);case f.BOOL:return t===!1?o?!1:void 0:(p(typeof t=="boolean"),t);case f.UINT64:case f.FIXED64:p(typeof t=="number"||typeof t=="string"||typeof t=="bigint");let a=D.from(t);return a.isZero()&&!o?void 0:a.toString();case f.INT64:case f.SFIXED64:case f.SINT64:p(typeof t=="number"||typeof t=="string"||typeof t=="bigint");let l=E.from(t);return l.isZero()&&!o?void 0:l.toString();case f.BYTES:return p(t instanceof Uint8Array),t.byteLength?Re(t):o?"":void 0}}}function fe(r,e=V.STRING){switch(r){case f.BOOL:return!1;case f.UINT64:case f.FIXED64:return _(D.ZERO,e);case f.INT64:case f.SFIXED64:case f.SINT64:return _(E.ZERO,e);case f.DOUBLE:case f.FLOAT:return 0;case f.BYTES:return new Uint8Array(0);case f.STRING:return"";default:return 0}}class ut{constructor(e){this.info=e}prepare(){var e;if(!this.fieldNoToField){const t=(e=this.info.fields)!==null&&e!==void 0?e:[];this.fieldNoToField=new Map(t.map(n=>[n.no,n]))}}read(e,t,n,s){this.prepare();const i=s===void 0?e.len:e.pos+s;for(;e.post.no-n.no)}}write(e,t,n){this.prepare();for(const i of this.fields){let o,a,l=i.repeat,c=i.localName;if(i.oneof){const h=e[i.oneof];if(h.oneofKind!==c)continue;o=h[c],a=!0}else o=e[c],a=!1;switch(i.kind){case"scalar":case"enum":let h=i.kind=="enum"?f.INT32:i.T;if(l)if(p(Array.isArray(o)),l==ee.PACKED)this.packed(t,h,i.no,o);else for(const m of o)this.scalar(t,h,i.no,m,!0);else o===void 0?p(i.opt):this.scalar(t,h,i.no,o,a||i.opt);break;case"message":if(l){p(Array.isArray(o));for(const m of o)this.message(t,n,i.T(),i.no,m)}else this.message(t,n,i.T(),i.no,o);break;case"map":p(typeof o=="object"&&o!==null);for(const[m,g]of Object.entries(o))this.mapEntry(t,n,i,m,g);break}}let s=n.writeUnknownFields;s!==!1&&(s===!0?F.onWrite:s)(this.info.typeName,e,t)}mapEntry(e,t,n,s,i){e.tag(n.no,y.LengthDelimited),e.fork();let o=s;switch(n.K){case f.INT32:case f.FIXED32:case f.UINT32:case f.SFIXED32:case f.SINT32:o=Number.parseInt(s);break;case f.BOOL:p(s=="true"||s=="false"),o=s=="true";break}switch(this.scalar(e,n.K,1,o,!0),n.V.kind){case"scalar":this.scalar(e,n.V.T,2,i,!0);break;case"enum":this.scalar(e,f.INT32,2,i,!0);break;case"message":this.message(e,t,n.V.T(),2,i);break}e.join()}message(e,t,n,s,i){i!==void 0&&(n.internalBinaryWrite(i,e.tag(s,y.LengthDelimited).fork(),t),e.join())}scalar(e,t,n,s,i){let[o,a,l]=this.scalarInfo(t,s);(!l||i)&&(e.tag(n,o),e[a](s))}packed(e,t,n,s){if(!s.length)return;p(t!==f.BYTES&&t!==f.STRING),e.tag(n,y.LengthDelimited),e.fork();let[,i]=this.scalarInfo(t);for(let o=0;ogt(s,this)),this.options=n??{}}}class N extends Error{constructor(e,t="UNKNOWN",n){super(e),this.name="RpcError",Object.setPrototypeOf(this,new.target.prototype),this.code=t,this.meta=n??{}}toString(){const e=[this.name+": "+this.message];this.code&&(e.push(""),e.push("Code: "+this.code)),this.serviceName&&this.methodName&&e.push("Method: "+this.serviceName+"/"+this.methodName);let t=Object.entries(this.meta);if(t.length){e.push(""),e.push("Meta:");for(let[n,s]of t)e.push(` ${n}: ${s}`)}return e.join(` +`)}}function Nt(r,e){if(!e)return r;let t={};H(r,t),H(e,t);for(let n of Object.keys(e)){let s=e[n];switch(n){case"jsonOptions":t.jsonOptions=it(r.jsonOptions,t.jsonOptions);break;case"binaryOptions":t.binaryOptions=Xe(r.binaryOptions,t.binaryOptions);break;case"meta":t.meta={},H(r.meta,t.meta),H(e.meta,t.meta);break;case"interceptors":t.interceptors=r.interceptors?r.interceptors.concat(s):s.concat();break}}return t}function H(r,e){if(!r)return;let t=e;for(let[n,s]of Object.entries(r))s instanceof Date?t[n]=new Date(s.getTime()):Array.isArray(s)?t[n]=s.concat():t[n]=s}var L;(function(r){r[r.PENDING=0]="PENDING",r[r.REJECTED=1]="REJECTED",r[r.RESOLVED=2]="RESOLVED"})(L||(L={}));class M{constructor(e=!0){this._state=L.PENDING,this._promise=new Promise((t,n)=>{this._resolve=t,this._reject=n}),e&&this._promise.catch(t=>{})}get state(){return this._state}get promise(){return this._promise}resolve(e){if(this.state!==L.PENDING)throw new Error(`cannot resolve ${L[this.state].toLowerCase()}`);this._resolve(e),this._state=L.RESOLVED}reject(e){if(this.state!==L.PENDING)throw new Error(`cannot reject ${L[this.state].toLowerCase()}`);this._reject(e),this._state=L.REJECTED}resolvePending(e){this._state===L.PENDING&&this.resolve(e)}rejectPending(e){this._state===L.PENDING&&this.reject(e)}}class wt{constructor(){this._lis={nxt:[],msg:[],err:[],cmp:[]},this._closed=!1,this._itState={q:[]}}onNext(e){return this.addLis(e,this._lis.nxt)}onMessage(e){return this.addLis(e,this._lis.msg)}onError(e){return this.addLis(e,this._lis.err)}onComplete(e){return this.addLis(e,this._lis.cmp)}addLis(e,t){return t.push(e),()=>{let n=t.indexOf(e);n>=0&&t.splice(n,1)}}clearLis(){for(let e of Object.values(this._lis))e.splice(0,e.length)}get closed(){return this._closed!==!1}notifyNext(e,t,n){p((e?1:0)+(t?1:0)+(n?1:0)<=1,"only one emission at a time"),e&&this.notifyMessage(e),t&&this.notifyError(t),n&&this.notifyComplete()}notifyMessage(e){p(!this.closed,"stream is closed"),this.pushIt({value:e,done:!1}),this._lis.msg.forEach(t=>t(e)),this._lis.nxt.forEach(t=>t(e,void 0,!1))}notifyError(e){p(!this.closed,"stream is closed"),this._closed=e,this.pushIt(e),this._lis.err.forEach(t=>t(e)),this._lis.nxt.forEach(t=>t(void 0,e,!1)),this.clearLis()}notifyComplete(){p(!this.closed,"stream is closed"),this._closed=!0,this.pushIt({value:null,done:!0}),this._lis.cmp.forEach(e=>e()),this._lis.nxt.forEach(e=>e(void 0,void 0,!0)),this.clearLis()}[Symbol.asyncIterator](){return this._closed===!0?this.pushIt({value:null,done:!0}):this._closed!==!1&&this.pushIt(this._closed),{next:()=>{let e=this._itState;p(e,"bad state"),p(!e.p,"iterator contract broken");let t=e.q.shift();return t?"value"in t?Promise.resolve(t):Promise.reject(t):(e.p=new M,e.p.promise)}}}pushIt(e){let t=this._itState;if(t.p){const n=t.p;p(n.state==L.PENDING,"iterator contract broken"),"value"in e?n.resolve(e):n.reject(e),delete t.p}else t.q.push(e)}}var yt=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};class Et{constructor(e,t,n,s,i,o,a){this.method=e,this.requestHeaders=t,this.request=n,this.headers=s,this.response=i,this.status=o,this.trailers=a}then(e,t){return this.promiseFinished().then(n=>e?Promise.resolve(e(n)):n,n=>t?Promise.resolve(t(n)):Promise.reject(n))}promiseFinished(){return yt(this,void 0,void 0,function*(){let[e,t,n,s]=yield Promise.all([this.headers,this.response,this.status,this.trailers]);return{method:this.method,requestHeaders:this.requestHeaders,request:this.request,headers:e,response:t,status:n,trailers:s}})}}var It=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};class Tt{constructor(e,t,n,s,i,o,a){this.method=e,this.requestHeaders=t,this.request=n,this.headers=s,this.responses=i,this.status=o,this.trailers=a}then(e,t){return this.promiseFinished().then(n=>e?Promise.resolve(e(n)):n,n=>t?Promise.resolve(t(n)):Promise.reject(n))}promiseFinished(){return It(this,void 0,void 0,function*(){let[e,t,n]=yield Promise.all([this.headers,this.status,this.trailers]);return{method:this.method,requestHeaders:this.requestHeaders,request:this.request,headers:e,status:t,trailers:n}})}}function ye(r,e,t,n,s){var i;{let o=(a,l,c)=>e.unary(a,l,c);for(const a of((i=n.interceptors)!==null&&i!==void 0?i:[]).filter(l=>l.interceptUnary).reverse()){const l=o;o=(c,h,m)=>a.interceptUnary(l,c,h,m)}return o(t,s,n)}}var u;(function(r){r[r.OK=0]="OK",r[r.CANCELLED=1]="CANCELLED",r[r.UNKNOWN=2]="UNKNOWN",r[r.INVALID_ARGUMENT=3]="INVALID_ARGUMENT",r[r.DEADLINE_EXCEEDED=4]="DEADLINE_EXCEEDED",r[r.NOT_FOUND=5]="NOT_FOUND",r[r.ALREADY_EXISTS=6]="ALREADY_EXISTS",r[r.PERMISSION_DENIED=7]="PERMISSION_DENIED",r[r.UNAUTHENTICATED=16]="UNAUTHENTICATED",r[r.RESOURCE_EXHAUSTED=8]="RESOURCE_EXHAUSTED",r[r.FAILED_PRECONDITION=9]="FAILED_PRECONDITION",r[r.ABORTED=10]="ABORTED",r[r.OUT_OF_RANGE=11]="OUT_OF_RANGE",r[r.UNIMPLEMENTED=12]="UNIMPLEMENTED",r[r.INTERNAL=13]="INTERNAL",r[r.UNAVAILABLE=14]="UNAVAILABLE",r[r.DATA_LOSS=15]="DATA_LOSS"})(u||(u={}));var Ot=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};function Ee(r,e,t,n,s){if(n)for(let[i,o]of Object.entries(n))if(typeof o=="string")r.append(i,o);else for(let a of o)r.append(i,a);if(r.set("Content-Type",e==="text"?"application/grpc-web-text":"application/grpc-web+proto"),e=="text"&&r.set("Accept","application/grpc-web-text"),r.set("X-Grpc-Web","1"),typeof t=="number"){if(t<=0)throw new N(`timeout ${t} ms exceeded`,u[u.DEADLINE_EXCEEDED]);r.set("grpc-timeout",`${t}m`)}else if(t){const i=t.getTime(),o=Date.now();if(i<=o)throw new N(`deadline ${t} exceeded`,u[u.DEADLINE_EXCEEDED]);r.set("grpc-timeout",`${i-o}m`)}return r}function Ie(r,e){let t=new Uint8Array(5+r.length);t[0]=U.DATA;for(let n=r.length,s=4;s>0;s--)t[s]=n%256,n>>>=8;return t.set(r,5),e==="binary"?t:Re(t)}function ue(r,e,t){if(arguments.length===1){let l=r,c;try{c=l.type}catch{}switch(c){case"error":case"opaque":case"opaqueredirect":throw new N(`fetch response type ${l.type}`,u[u.UNKNOWN])}return ue(Rt(l.headers),l.status,l.statusText)}let n=r,s=e>=200&&e<300,i=ve(n),[o,a]=Ve(n);return(o===void 0||o===u.OK)&&!s&&(o=Bt(e),a=t),[o,a,i]}function Te(r){let e=Dt(r),[t,n]=Ve(e),s=ve(e);return[t??u.OK,n,s]}var U;(function(r){r[r.DATA=0]="DATA",r[r.TRAILER=128]="TRAILER"})(U||(U={}));function Oe(r,e,t){return Ot(this,void 0,void 0,function*(){let n,s="",i=new Uint8Array(0),o=At(e);if(kt(r)){let a=r.getReader();n={next:()=>a.read()}}else n=r[Symbol.asyncIterator]();for(;;){let a=yield n.next();if(a.value!==void 0){if(o==="text"){for(let c=0;c=5&&i[0]===U.DATA;){let l=0;for(let c=1;c<5;c++)l=(l<<8)+i[c];if(i.length-5>=l)t(U.DATA,i.subarray(5,5+l)),i=i.subarray(5+l);else break}}if(a.done){if(i.length===0)break;if(i[0]!==U.TRAILER||i.length<5)throw new N("premature EOF",u[u.DATA_LOSS]);t(U.TRAILER,i.subarray(5));break}}})}const kt=r=>typeof r.getReader=="function";function ke(r,e){let t=new Uint8Array(r.length+e.length);return t.set(r),t.set(e,r.length),t}function At(r){switch(r){case"application/grpc-web-text":case"application/grpc-web-text+proto":return"text";case"application/grpc-web":case"application/grpc-web+proto":return"binary";case void 0:case null:throw new N("missing response content type",u[u.INTERNAL]);default:throw new N("unexpected response content type: "+r,u[u.INTERNAL])}}function Ve(r){let e,t,n=r["grpc-message"];if(n!==void 0){if(Array.isArray(n))return[u.INTERNAL,"invalid grpc-web message"];t=n}let s=r["grpc-status"];if(s!==void 0){if(Array.isArray(s))return[u.INTERNAL,"invalid grpc-web status"];if(e=parseInt(s,10),u[e]===void 0)return[u.INTERNAL,"invalid grpc-web status"]}return[e,t]}function ve(r){let e={};for(let[t,n]of Object.entries(r))switch(t){case"grpc-message":case"grpc-status":case"content-type":break;default:e[t]=n}return e}function Dt(r){let e={};for(let t of String.fromCharCode.apply(String,r).trim().split(`\r +`)){if(t=="")continue;let[n,...s]=t.split(":");const i=s.join(":").trim();n=n.trim();let o=e[n];typeof o=="string"?e[n]=[o,i]:Array.isArray(o)?o.push(i):e[n]=i}return e}function Rt(r){let e={};return r.forEach((t,n)=>{let s=e[n];typeof s=="string"?e[n]=[s,t]:Array.isArray(s)?s.push(t):e[n]=t}),e}function Bt(r){switch(r){case 200:return u.OK;case 400:return u.INVALID_ARGUMENT;case 401:return u.UNAUTHENTICATED;case 403:return u.PERMISSION_DENIED;case 404:return u.NOT_FOUND;case 409:return u.ABORTED;case 412:return u.FAILED_PRECONDITION;case 429:return u.RESOURCE_EXHAUSTED;case 499:return u.CANCELLED;case 500:return u.UNKNOWN;case 501:return u.UNIMPLEMENTED;case 503:return u.UNAVAILABLE;case 504:return u.DEADLINE_EXCEEDED;default:return u.UNKNOWN}}class Lt{constructor(e){this.defaultOptions=e}mergeOptions(e){return Nt(this.defaultOptions,e)}makeUrl(e,t){let n=t.baseUrl;return n.endsWith("/")&&(n=n.substring(0,n.length-1)),`${n}/${e.service.typeName}/${e.name}`}clientStreaming(e){const t=new N("Client streaming is not supported by grpc-web",u[u.UNIMPLEMENTED]);throw t.methodName=e.name,t.serviceName=e.service.typeName,t}duplex(e){const t=new N("Duplex streaming is not supported by grpc-web",u[u.UNIMPLEMENTED]);throw t.methodName=e.name,t.serviceName=e.service.typeName,t}serverStreaming(e,t,n){var s,i,o,a,l;let c=n,h=(s=c.format)!==null&&s!==void 0?s:"text",m=(i=c.fetch)!==null&&i!==void 0?i:globalThis.fetch,g=(o=c.fetchInit)!==null&&o!==void 0?o:{},O=this.makeUrl(e,c),R=e.I.toBinary(t,c.binaryOptions),v=new M,k=new wt,S=!0,T,C=new M,x,K=new M;return m(O,Object.assign(Object.assign({},g),{method:"POST",headers:Ee(new globalThis.Headers,h,c.timeout,c.meta),body:Ie(R,h),signal:(a=n.abort)!==null&&a!==void 0?a:null})).then(b=>{let[d,I,B]=ue(b);if(v.resolve(B),d!=null&&d!==u.OK)throw new N(I??u[d],u[d],B);return d!=null&&(T={code:u[d],detail:I??u[d]}),b}).then(b=>{if(!b.body)throw new N("missing response body",u[u.INTERNAL]);return Oe(b.body,b.headers.get("content-type"),(d,I)=>{switch(d){case U.DATA:k.notifyMessage(e.O.fromBinary(I,c.binaryOptions)),S=!1;break;case U.TRAILER:let B,P;[B,P,x]=Te(I),T={code:u[B],detail:P??u[B]};break}})}).then(()=>{if(!x&&!S)throw new N("missing trailers",u[u.DATA_LOSS]);if(!T)throw new N("missing status",u[u.INTERNAL]);if(T.code!=="OK")throw new N(T.detail,T.code,x);k.notifyComplete(),C.resolve(T),K.resolve(x||{})}).catch(b=>{let d;b instanceof N?d=b:b instanceof Error&&b.name==="AbortError"?d=new N(b.message,u[u.CANCELLED]):d=new N(b instanceof Error?b.message:""+b,u[u.INTERNAL]),d.methodName=e.name,d.serviceName=e.service.typeName,v.rejectPending(d),k.notifyError(d),C.rejectPending(d),K.rejectPending(d)}),new Tt(e,(l=c.meta)!==null&&l!==void 0?l:{},t,v.promise,k,C.promise,K.promise)}unary(e,t,n){var s,i,o,a,l;let c=n,h=(s=c.format)!==null&&s!==void 0?s:"text",m=(i=c.fetch)!==null&&i!==void 0?i:globalThis.fetch,g=(o=c.fetchInit)!==null&&o!==void 0?o:{},O=this.makeUrl(e,c),R=e.I.toBinary(t,c.binaryOptions),v=new M,k,S=new M,T,C=new M,x,K=new M;return m(O,Object.assign(Object.assign({},g),{method:"POST",headers:Ee(new globalThis.Headers,h,c.timeout,c.meta),body:Ie(R,h),signal:(a=n.abort)!==null&&a!==void 0?a:null})).then(b=>{let[d,I,B]=ue(b);if(v.resolve(B),d!=null&&d!==u.OK)throw new N(I??u[d],u[d],B);return d!=null&&(T={code:u[d],detail:I??u[d]}),b}).then(b=>{if(!b.body)throw new N("missing response body",u[u.INTERNAL]);return Oe(b.body,b.headers.get("content-type"),(d,I)=>{switch(d){case U.DATA:if(k)throw new N("unary call received 2nd message",u[u.DATA_LOSS]);k=e.O.fromBinary(I,c.binaryOptions);break;case U.TRAILER:let B,P;[B,P,x]=Te(I),T={code:u[B],detail:P??u[B]};break}})}).then(()=>{if(!x&&k)throw new N("missing trailers",u[u.DATA_LOSS]);if(!T)throw new N("missing status",u[u.INTERNAL]);if(!k&&T.code==="OK")throw new N("expected error status",u[u.DATA_LOSS]);if(!k)throw new N(T.detail,T.code,x);if(S.resolve(k),T.code!=="OK")throw new N(T.detail,T.code,x);C.resolve(T),K.resolve(x||{})}).catch(b=>{let d;b instanceof N?d=b:b instanceof Error&&b.name==="AbortError"?d=new N(b.message,u[u.CANCELLED]):d=new N(b instanceof Error?b.message:""+b,u[u.INTERNAL]),d.methodName=e.name,d.serviceName=e.service.typeName,v.rejectPending(d),S.rejectPending(d),C.rejectPending(d),K.rejectPending(d)}),new Et(e,(l=c.meta)!==null&&l!==void 0?l:{},t,v.promise,S.promise,C.promise,K.promise)}}class Ft extends se{constructor(){super("grpcweb.example.v1.HelloRequest",[{no:1,name:"name",kind:"scalar",T:9}])}create(e){const t=globalThis.Object.create(this.messagePrototype);return t.name="",e!==void 0&&X(this,t,e),t}internalBinaryRead(e,t,n,s){let i=s??this.create(),o=e.pos+t;for(;e.pos50&&Z.shift(),ae.textContent=Z.join(` +`),ae.scrollTop=ae.scrollHeight}function j(r,e,t=!1){r.textContent=typeof e=="object"?JSON.stringify(e,null,2):String(e),r.className="result "+(t?"error":"success")}async function Jt(){const r=$e.value||"Anonymous";A(`[HTTP/JSON] 发送请求到 /v1/greeter/hello,name=${r}`);try{const t=await(await fetch("/v1/greeter/hello",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:r})})).json();A(`[HTTP/JSON] 收到响应: ${JSON.stringify(t)}`),j(te,t)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[HTTP/JSON] 错误: ${t}`),j(te,t,!0)}}async function Wt(){const r=Me.value||"Anonymous";A(`[HTTP/JSON] 发送请求到 /v1/greeter/goodbye,name=${r}`);try{const t=await(await fetch("/v1/greeter/goodbye",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:r})})).json();A(`[HTTP/JSON] 收到响应: ${JSON.stringify(t)}`),j(ne,t)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[HTTP/JSON] 错误: ${t}`),j(ne,t,!0)}}async function Gt(){const r=$e.value||"Anonymous";A(`[gRPC Web] 调用 GreeterService.SayHello,name=${r}`);try{const e=Pe.sayHello({name:r}),t=await e.response;A(`[gRPC Web] 收到响应: message=${t.message}, timestamp=${t.timestamp}`),j(te,{message:t.message,timestamp:t.timestamp.toString()});const n=await e.status;A(`[gRPC Web] 状态: ${n.code}`)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[gRPC Web] 错误: ${t}`),j(te,t,!0)}}async function qt(){const r=Me.value||"Anonymous";A(`[gRPC Web] 调用 GreeterService.SayGoodbye,name=${r}`);try{const e=Pe.sayGoodbye({name:r}),t=await e.response;A(`[gRPC Web] 收到响应: message=${t.message}, timestamp=${t.timestamp}`),j(ne,{message:t.message,timestamp:t.timestamp.toString()});const n=await e.status;A(`[gRPC Web] 状态: ${n.code}`)}catch(e){const t=e instanceof Error?e.message:String(e);A(`[gRPC Web] 错误: ${t}`),j(ne,t,!0)}}jt.addEventListener("click",Jt);Ct.addEventListener("click",Gt);Kt.addEventListener("click",Wt);Xt.addEventListener("click",qt);A("页面加载完成,protobuf-ts gRPC Web 客户端已初始化");A(`Transport: GrpcWebFetchTransport, baseUrl: ${window.location.origin}`); diff --git a/internal/examples/grpcweb/static/assets/index-B294X0fC.js b/internal/examples/grpcweb/static/assets/index-B294X0fC.js new file mode 100644 index 000000000..7ebb1fe80 --- /dev/null +++ b/internal/examples/grpcweb/static/assets/index-B294X0fC.js @@ -0,0 +1,5 @@ +var Me=Object.defineProperty;var Ce=(r,e,t)=>e in r?Me(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t;var W=(r,e,t)=>Ce(r,typeof e!="symbol"?e+"":e,t);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function t(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=t(s);fetch(s.href,i)}})();function Ae(r){let e=typeof r;if(e=="object"){if(Array.isArray(r))return"array";if(r===null)return"null"}return e}function Ke(r){return r!==null&&typeof r=="object"&&!Array.isArray(r)}let $="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split(""),re=[];for(let r=0;r<$.length;r++)re[$[r].charCodeAt(0)]=r;re[45]=$.indexOf("+");re[95]=$.indexOf("/");function De(r){let e=r.length*3/4;r[r.length-2]=="="?e-=2:r[r.length-1]=="="&&(e-=1);let t=new Uint8Array(e),n=0,s=0,i,o=0;for(let a=0;a>4,o=i,s=2;break;case 2:t[n++]=(o&15)<<4|(i&60)>>2,o=i,s=3;break;case 3:t[n++]=(o&3)<<6|i,s=0;break}}if(s==1)throw Error("invalid base64 string.");return t.subarray(0,n)}function Re(r){let e="",t=0,n,s=0;for(let i=0;i>2],s=(n&3)<<4,t=1;break;case 1:e+=$[s|n>>4],s=(n&15)<<2,t=2;break;case 2:e+=$[s|n>>6],e+=$[n&63],t=0;break}return t&&(e+=$[s],e+="=",t==1&&(e+="=")),e}var F;(function(r){r.symbol=Symbol.for("protobuf-ts/unknown"),r.onRead=(t,n,s,i,o)=>{(e(n)?n[r.symbol]:n[r.symbol]=[]).push({no:s,wireType:i,data:o})},r.onWrite=(t,n,s)=>{for(let{no:i,wireType:o,data:a}of r.list(n))s.tag(i,o).raw(a)},r.list=(t,n)=>{if(e(t)){let s=t[r.symbol];return n?s.filter(i=>i.no==n):s}return[]},r.last=(t,n)=>r.list(t,n).slice(-1)[0];const e=t=>t&&Array.isArray(t[r.symbol])})(F||(F={}));function Je(r,e){return Object.assign(Object.assign({},r),e)}var y;(function(r){r[r.Varint=0]="Varint",r[r.Bit64=1]="Bit64",r[r.LengthDelimited=2]="LengthDelimited",r[r.StartGroup=3]="StartGroup",r[r.EndGroup=4]="EndGroup",r[r.Bit32=5]="Bit32"})(y||(y={}));function Xe(){let r=0,e=0;for(let n=0;n<28;n+=7){let s=this.buf[this.pos++];if(r|=(s&127)<>4,(t&128)==0)return this.assertBounds(),[r,e];for(let n=3;n<=31;n+=7){let s=this.buf[this.pos++];if(e|=(s&127)<>>i,a=!(!(o>>>7)&&e==0),l=(a?o|128:o)&255;if(t.push(l),!a)return}const n=r>>>28&15|(e&7)<<4,s=e>>3!=0;if(t.push((s?n|128:n)&255),!!s){for(let i=3;i<31;i=i+7){const o=e>>>i,a=!!(o>>>7),l=(a?o|128:o)&255;if(t.push(l),!a)return}t.push(e>>>31&1)}}const Y=65536*65536;function Be(r){let e=r[0]=="-";e&&(r=r.slice(1));const t=1e6;let n=0,s=0;function i(o,a){const l=Number(r.slice(o,a));s*=t,n=n*t+l,n>=Y&&(s=s+(n/Y|0),n=n%Y)}return i(-24,-18),i(-18,-12),i(-12,-6),i(-6),[e,n,s]}function le(r,e){if(e>>>0<=2097151)return""+(Y*e+(r>>>0));let t=r&16777215,n=(r>>>24|e<<8)>>>0&16777215,s=e>>16&65535,i=t+n*6777216+s*6710656,o=n+s*8147497,a=s*2,l=1e7;i>=l&&(o+=Math.floor(i/l),i%=l),o>=l&&(a+=Math.floor(o/l),o%=l);function c(h,m){let g=h?String(h):"";return m?"0000000".slice(g.length)+g:g}return c(a,0)+c(o,a)+c(i,1)}function me(r,e){if(r>=0){for(;r>127;)e.push(r&127|128),r=r>>>7;e.push(r)}else{for(let t=0;t<9;t++)e.push(r&127|128),r=r>>7;e.push(1)}}function We(){let r=this.buf[this.pos++],e=r&127;if((r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<7,(r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<14,(r&128)==0)return this.assertBounds(),e;if(r=this.buf[this.pos++],e|=(r&127)<<21,(r&128)==0)return this.assertBounds(),e;r=this.buf[this.pos++],e|=(r&15)<<28;for(let t=5;(r&128)!==0&&t<10;t++)r=this.buf[this.pos++];if((r&128)!=0)throw new Error("invalid varint");return this.assertBounds(),e>>>0}let w;function Ge(){const r=new DataView(new ArrayBuffer(8));w=globalThis.BigInt!==void 0&&typeof r.getBigInt64=="function"&&typeof r.getBigUint64=="function"&&typeof r.setBigInt64=="function"&&typeof r.setBigUint64=="function"?{MIN:BigInt("-9223372036854775808"),MAX:BigInt("9223372036854775807"),UMIN:BigInt("0"),UMAX:BigInt("18446744073709551615"),C:BigInt,V:r}:void 0}Ge();function Le(r){if(!r)throw new Error("BigInt unavailable, see https://github.com/timostamm/protobuf-ts/blob/v1.0.8/MANUAL.md#bigint-support")}const Fe=/^-?[0-9]+$/,z=4294967296,G=2147483648;class _e{constructor(e,t){this.lo=e|0,this.hi=t|0}isZero(){return this.lo==0&&this.hi==0}toNumber(){let e=this.hi*z+(this.lo>>>0);if(!Number.isSafeInteger(e))throw new Error("cannot convert to safe number");return e}}class D extends _e{static from(e){if(w)switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=="")throw new Error("string is no integer");e=w.C(e);case"number":if(e===0)return this.ZERO;e=w.C(e);case"bigint":if(!e)return this.ZERO;if(ew.UMAX)throw new Error("ulong too large");return w.V.setBigUint64(0,e,!0),new D(w.V.getInt32(0,!0),w.V.getInt32(4,!0))}else switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=e.trim(),!Fe.test(e))throw new Error("string is no integer");let[t,n,s]=Be(e);if(t)throw new Error("signed value for ulong");return new D(n,s);case"number":if(e==0)return this.ZERO;if(!Number.isSafeInteger(e))throw new Error("number is no integer");if(e<0)throw new Error("signed value for ulong");return new D(e,e/z)}throw new Error("unknown value "+typeof e)}toString(){return w?this.toBigInt().toString():le(this.lo,this.hi)}toBigInt(){return Le(w),w.V.setInt32(0,this.lo,!0),w.V.setInt32(4,this.hi,!0),w.V.getBigUint64(0,!0)}}D.ZERO=new D(0,0);class E extends _e{static from(e){if(w)switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=="")throw new Error("string is no integer");e=w.C(e);case"number":if(e===0)return this.ZERO;e=w.C(e);case"bigint":if(!e)return this.ZERO;if(ew.MAX)throw new Error("signed long too large");return w.V.setBigInt64(0,e,!0),new E(w.V.getInt32(0,!0),w.V.getInt32(4,!0))}else switch(typeof e){case"string":if(e=="0")return this.ZERO;if(e=e.trim(),!Fe.test(e))throw new Error("string is no integer");let[t,n,s]=Be(e);if(t){if(s>G||s==G&&n!=0)throw new Error("signed long too small")}else if(s>=G)throw new Error("signed long too large");let i=new E(n,s);return t?i.negate():i;case"number":if(e==0)return this.ZERO;if(!Number.isSafeInteger(e))throw new Error("number is no integer");return e>0?new E(e,e/z):new E(-e,-e/z).negate()}throw new Error("unknown value "+typeof e)}isNegative(){return(this.hi&G)!==0}negate(){let e=~this.hi,t=this.lo;return t?t=~t+1:e+=1,new E(t,e)}toString(){if(w)return this.toBigInt().toString();if(this.isNegative()){let e=this.negate();return"-"+le(e.lo,e.hi)}return le(this.lo,this.hi)}toBigInt(){return Le(w),w.V.setInt32(0,this.lo,!0),w.V.setInt32(4,this.hi,!0),w.V.getBigInt64(0,!0)}}E.ZERO=new E(0,0);const de={readUnknownField:!0,readerFactory:r=>new He(r)};function qe(r){return r?Object.assign(Object.assign({},de),r):de}class He{constructor(e,t){this.varint64=Xe,this.uint32=We,this.buf=e,this.len=e.length,this.pos=0,this.view=new DataView(e.buffer,e.byteOffset,e.byteLength),this.textDecoder=t??new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0})}tag(){let e=this.uint32(),t=e>>>3,n=e&7;if(t<=0||n<0||n>5)throw new Error("illegal tag: field no "+t+" wire type "+n);return[t,n]}skip(e){let t=this.pos;switch(e){case y.Varint:for(;this.buf[this.pos++]&128;);break;case y.Bit64:this.pos+=4;case y.Bit32:this.pos+=4;break;case y.LengthDelimited:let n=this.uint32();this.pos+=n;break;case y.StartGroup:let s;for(;(s=this.tag()[1])!==y.EndGroup;)this.skip(s);break;default:throw new Error("cant skip wire type "+e)}return this.assertBounds(),this.buf.subarray(t,this.pos)}assertBounds(){if(this.pos>this.len)throw new RangeError("premature EOF")}int32(){return this.uint32()|0}sint32(){let e=this.uint32();return e>>>1^-(e&1)}int64(){return new E(...this.varint64())}uint64(){return new D(...this.varint64())}sint64(){let[e,t]=this.varint64(),n=-(e&1);return e=(e>>>1|(t&1)<<31)^n,t=t>>>1^n,new E(e,t)}bool(){let[e,t]=this.varint64();return e!==0||t!==0}fixed32(){return this.view.getUint32((this.pos+=4)-4,!0)}sfixed32(){return this.view.getInt32((this.pos+=4)-4,!0)}fixed64(){return new D(this.sfixed32(),this.sfixed32())}sfixed64(){return new E(this.sfixed32(),this.sfixed32())}float(){return this.view.getFloat32((this.pos+=4)-4,!0)}double(){return this.view.getFloat64((this.pos+=8)-8,!0)}bytes(){let e=this.uint32(),t=this.pos;return this.pos+=e,this.assertBounds(),this.buf.subarray(t,t+e)}string(){return this.textDecoder.decode(this.bytes())}}function p(r,e){if(!r)throw new Error(e)}const Ze=34028234663852886e22,Ye=-34028234663852886e22,ze=4294967295,Qe=2147483647,et=-2147483648;function X(r){if(typeof r!="number")throw new Error("invalid int 32: "+typeof r);if(!Number.isInteger(r)||r>Qe||rze||r<0)throw new Error("invalid uint 32: "+r)}function he(r){if(typeof r!="number")throw new Error("invalid float 32: "+typeof r);if(Number.isFinite(r)&&(r>Ze||rnew nt};function tt(r){return r?Object.assign(Object.assign({},pe),r):pe}class nt{constructor(e){this.stack=[],this.textEncoder=e??new TextEncoder,this.chunks=[],this.buf=[]}finish(){this.chunks.push(new Uint8Array(this.buf));let e=0;for(let s=0;s>>0)}raw(e){return this.buf.length&&(this.chunks.push(new Uint8Array(this.buf)),this.buf=[]),this.chunks.push(e),this}uint32(e){for(Q(e);e>127;)this.buf.push(e&127|128),e=e>>>7;return this.buf.push(e),this}int32(e){return X(e),me(e,this.buf),this}bool(e){return this.buf.push(e?1:0),this}bytes(e){return this.uint32(e.byteLength),this.raw(e)}string(e){let t=this.textEncoder.encode(e);return this.uint32(t.byteLength),this.raw(t)}float(e){he(e);let t=new Uint8Array(4);return new DataView(t.buffer).setFloat32(0,e,!0),this.raw(t)}double(e){let t=new Uint8Array(8);return new DataView(t.buffer).setFloat64(0,e,!0),this.raw(t)}fixed32(e){Q(e);let t=new Uint8Array(4);return new DataView(t.buffer).setUint32(0,e,!0),this.raw(t)}sfixed32(e){X(e);let t=new Uint8Array(4);return new DataView(t.buffer).setInt32(0,e,!0),this.raw(t)}sint32(e){return X(e),e=(e<<1^e>>31)>>>0,me(e,this.buf),this}sfixed64(e){let t=new Uint8Array(8),n=new DataView(t.buffer),s=E.from(e);return n.setInt32(0,s.lo,!0),n.setInt32(4,s.hi,!0),this.raw(t)}fixed64(e){let t=new Uint8Array(8),n=new DataView(t.buffer),s=D.from(e);return n.setInt32(0,s.lo,!0),n.setInt32(4,s.hi,!0),this.raw(t)}int64(e){let t=E.from(e);return ie(t.lo,t.hi,this.buf),this}sint64(e){let t=E.from(e),n=t.hi>>31,s=t.lo<<1^n,i=(t.hi<<1|t.lo>>>31)^n;return ie(s,i,this.buf),this}uint64(e){let t=D.from(e);return ie(t.lo,t.hi,this.buf),this}}const ge={emitDefaultValues:!1,enumAsInteger:!1,useProtoFieldName:!1,prettySpaces:0},be={ignoreUnknownFields:!1};function rt(r){return r?Object.assign(Object.assign({},be),r):be}function st(r){return r?Object.assign(Object.assign({},ge),r):ge}function it(r,e){var t,n;let s=Object.assign(Object.assign({},r),e);return s.typeRegistry=[...(t=r==null?void 0:r.typeRegistry)!==null&&t!==void 0?t:[],...(n=e==null?void 0:e.typeRegistry)!==null&&n!==void 0?n:[]],s}const Se=Symbol.for("protobuf-ts/message-type");function ce(r){let e=!1;const t=[];for(let n=0;n!s.includes(o))||!n&&s.some(o=>!i.known.includes(o)))return!1;if(t<1)return!0;for(const o of i.oneofs){const a=e[o];if(!at(a))return!1;if(a.oneofKind===void 0)continue;const l=this.fields.find(c=>c.localName===a.oneofKind);if(!l||!this.field(a[a.oneofKind],l,n,t))return!1}for(const o of this.fields)if(o.oneof===void 0&&!this.field(e[o.localName],o,n,t))return!1;return!0}field(e,t,n,s){let i=t.repeat;switch(t.kind){case"scalar":return e===void 0?t.opt:i?this.scalars(e,t.T,s,t.L):this.scalar(e,t.T,t.L);case"enum":return e===void 0?t.opt:i?this.scalars(e,f.INT32,s):this.scalar(e,f.INT32);case"message":return e===void 0?!0:i?this.messages(e,t.T(),n,s):this.message(e,t.T(),n,s);case"map":if(typeof e!="object"||e===null)return!1;if(s<2)return!0;if(!this.mapKeys(e,t.K,s))return!1;switch(t.V.kind){case"scalar":return this.scalars(Object.values(e),t.V.T,s,t.V.L);case"enum":return this.scalars(Object.values(e),f.INT32,s);case"message":return this.messages(Object.values(e),t.V.T(),n,s)}break}return!0}message(e,t,n,s){return n?t.isAssignable(e,s):t.is(e,s)}messages(e,t,n,s){if(!Array.isArray(e))return!1;if(s<2)return!0;if(n){for(let i=0;iparseInt(i)),t,n);case f.BOOL:return this.scalars(s.slice(0,n).map(i=>i=="true"?!0:i=="false"?!1:i),t,n);default:return this.scalars(s,t,n,V.STRING)}}}function _(r,e){switch(e){case V.BIGINT:return r.toBigInt();case V.NUMBER:return r.toNumber();default:return r.toString()}}class ct{constructor(e){this.info=e}prepare(){var e;if(this.fMap===void 0){this.fMap={};const t=(e=this.info.fields)!==null&&e!==void 0?e:[];for(const n of t)this.fMap[n.name]=n,this.fMap[n.jsonName]=n,this.fMap[n.localName]=n}}assert(e,t,n){if(!e){let s=Ae(n);throw(s=="number"||s=="boolean")&&(s=n.toString()),new Error(`Cannot parse JSON ${s} for ${this.info.typeName}#${t}`)}}read(e,t,n){this.prepare();const s=[];for(const[i,o]of Object.entries(e)){const a=this.fMap[i];if(!a){if(!n.ignoreUnknownFields)throw new Error(`Found unknown field while reading ${this.info.typeName} from JSON format. JSON key: ${i}`);continue}const l=a.localName;let c;if(a.oneof){if(o===null&&(a.kind!=="enum"||a.T()[0]!=="google.protobuf.NullValue"))continue;if(s.includes(a.oneof))throw new Error(`Multiple members of the oneof group "${a.oneof}" of ${this.info.typeName} are present in JSON.`);s.push(a.oneof),c=t[a.oneof]={oneofKind:l}}else c=t;if(a.kind=="map"){if(o===null)continue;this.assert(Ke(o),a.name,o);const h=c[l];for(const[m,g]of Object.entries(o)){this.assert(g!==null,a.name+" map value",null);let k;switch(a.V.kind){case"message":k=a.V.T().internalJsonRead(g,n);break;case"enum":if(k=this.enum(a.V.T(),g,a.name,n.ignoreUnknownFields),k===!1)continue;break;case"scalar":k=this.scalar(g,a.V.T,a.V.L,a.name);break}this.assert(k!==void 0,a.name+" map value",g);let R=m;a.K==f.BOOL&&(R=R=="true"?!0:R=="false"?!1:R),R=this.scalar(R,a.K,V.STRING,a.name).toString(),h[R]=k}}else if(a.repeat){if(o===null)continue;this.assert(Array.isArray(o),a.name,o);const h=c[l];for(const m of o){this.assert(m!==null,a.name,null);let g;switch(a.kind){case"message":g=a.T().internalJsonRead(m,n);break;case"enum":if(g=this.enum(a.T(),m,a.name,n.ignoreUnknownFields),g===!1)continue;break;case"scalar":g=this.scalar(m,a.T,a.L,a.name);break}this.assert(g!==void 0,a.name,o),h.push(g)}}else switch(a.kind){case"message":if(o===null&&a.T().typeName!="google.protobuf.Value"){this.assert(a.oneof===void 0,a.name+" (oneof member)",null);continue}c[l]=a.T().internalJsonRead(o,n,c[l]);break;case"enum":if(o===null)continue;let h=this.enum(a.T(),o,a.name,n.ignoreUnknownFields);if(h===!1)continue;c[l]=h;break;case"scalar":if(o===null)continue;c[l]=this.scalar(o,a.T,a.L,a.name);break}}}enum(e,t,n,s){if(e[0]=="google.protobuf.NullValue"&&p(t===null||t==="NULL_VALUE",`Unable to parse field ${this.info.typeName}#${n}, enum ${e[0]} only accepts null.`),t===null)return 0;switch(typeof t){case"number":return p(Number.isInteger(t),`Unable to parse field ${this.info.typeName}#${n}, enum can only be integral number, got ${t}.`),t;case"string":let i=t;e[2]&&t.substring(0,e[2].length)===e[2]&&(i=t.substring(e[2].length));let o=e[1][i];return typeof o>"u"&&s?!1:(p(typeof o=="number",`Unable to parse field ${this.info.typeName}#${n}, enum ${e[0]} has no value for "${t}".`),o)}p(!1,`Unable to parse field ${this.info.typeName}#${n}, cannot parse enum value from ${typeof t}".`)}scalar(e,t,n,s){let i;try{switch(t){case f.DOUBLE:case f.FLOAT:if(e===null)return 0;if(e==="NaN")return Number.NaN;if(e==="Infinity")return Number.POSITIVE_INFINITY;if(e==="-Infinity")return Number.NEGATIVE_INFINITY;if(e===""){i="empty string";break}if(typeof e=="string"&&e.trim().length!==e.length){i="extra whitespace";break}if(typeof e!="string"&&typeof e!="number")break;let o=Number(e);if(Number.isNaN(o)){i="not a number";break}if(!Number.isFinite(o)){i="too large or small";break}return t==f.FLOAT&&he(o),o;case f.INT32:case f.FIXED32:case f.SFIXED32:case f.SINT32:case f.UINT32:if(e===null)return 0;let a;if(typeof e=="number"?a=e:e===""?i="empty string":typeof e=="string"&&(e.trim().length!==e.length?i="extra whitespace":a=Number(e)),a===void 0)break;return t==f.UINT32?Q(a):X(a),a;case f.INT64:case f.SFIXED64:case f.SINT64:if(e===null)return _(E.ZERO,n);if(typeof e!="number"&&typeof e!="string")break;return _(E.from(e),n);case f.FIXED64:case f.UINT64:if(e===null)return _(D.ZERO,n);if(typeof e!="number"&&typeof e!="string")break;return _(D.from(e),n);case f.BOOL:if(e===null)return!1;if(typeof e!="boolean")break;return e;case f.STRING:if(e===null)return"";if(typeof e!="string"){i="extra whitespace";break}try{encodeURIComponent(e)}catch(l){l="invalid UTF8";break}return e;case f.BYTES:if(e===null||e==="")return new Uint8Array(0);if(typeof e!="string")break;return De(e)}}catch(o){i=o.message}this.assert(!1,s+(i?" - "+i:""),e)}}class ft{constructor(e){var t;this.fields=(t=e.fields)!==null&&t!==void 0?t:[]}write(e,t){const n={},s=e;for(const i of this.fields){if(!i.oneof){let c=this.field(i,s[i.localName],t);c!==void 0&&(n[t.useProtoFieldName?i.name:i.jsonName]=c);continue}const o=s[i.oneof];if(o.oneofKind!==i.localName)continue;const a=i.kind=="scalar"||i.kind=="enum"?Object.assign(Object.assign({},t),{emitDefaultValues:!0}):t;let l=this.field(i,o[i.localName],a);p(l!==void 0),n[t.useProtoFieldName?i.name:i.jsonName]=l}return n}field(e,t,n){let s;if(e.kind=="map"){p(typeof t=="object"&&t!==null);const i={};switch(e.V.kind){case"scalar":for(const[l,c]of Object.entries(t)){const h=this.scalar(e.V.T,c,e.name,!1,!0);p(h!==void 0),i[l.toString()]=h}break;case"message":const o=e.V.T();for(const[l,c]of Object.entries(t)){const h=this.message(o,c,e.name,n);p(h!==void 0),i[l.toString()]=h}break;case"enum":const a=e.V.T();for(const[l,c]of Object.entries(t)){p(c===void 0||typeof c=="number");const h=this.enum(a,c,e.name,!1,!0,n.enumAsInteger);p(h!==void 0),i[l.toString()]=h}break}(n.emitDefaultValues||Object.keys(i).length>0)&&(s=i)}else if(e.repeat){p(Array.isArray(t));const i=[];switch(e.kind){case"scalar":for(let l=0;l0||n.emitDefaultValues)&&(s=i)}else switch(e.kind){case"scalar":s=this.scalar(e.T,t,e.name,e.opt,n.emitDefaultValues);break;case"enum":s=this.enum(e.T(),t,e.name,e.opt,n.emitDefaultValues,n.enumAsInteger);break;case"message":s=this.message(e.T(),t,e.name,n);break}return s}enum(e,t,n,s,i,o){if(e[0]=="google.protobuf.NullValue")return!i&&!s?void 0:null;if(t===void 0){p(s);return}if(!(t===0&&!i&&!s))return p(typeof t=="number"),p(Number.isInteger(t)),o||!e[1].hasOwnProperty(t)?t:e[2]?e[2]+e[1][t]:e[1][t]}message(e,t,n,s){return t===void 0?s.emitDefaultValues?null:void 0:e.internalJsonWrite(t,s)}scalar(e,t,n,s,i){if(t===void 0){p(s);return}const o=i||s;switch(e){case f.INT32:case f.SFIXED32:case f.SINT32:return t===0?o?0:void 0:(X(t),t);case f.FIXED32:case f.UINT32:return t===0?o?0:void 0:(Q(t),t);case f.FLOAT:he(t);case f.DOUBLE:return t===0?o?0:void 0:(p(typeof t=="number"),Number.isNaN(t)?"NaN":t===Number.POSITIVE_INFINITY?"Infinity":t===Number.NEGATIVE_INFINITY?"-Infinity":t);case f.STRING:return t===""?o?"":void 0:(p(typeof t=="string"),t);case f.BOOL:return t===!1?o?!1:void 0:(p(typeof t=="boolean"),t);case f.UINT64:case f.FIXED64:p(typeof t=="number"||typeof t=="string"||typeof t=="bigint");let a=D.from(t);return a.isZero()&&!o?void 0:a.toString();case f.INT64:case f.SFIXED64:case f.SINT64:p(typeof t=="number"||typeof t=="string"||typeof t=="bigint");let l=E.from(t);return l.isZero()&&!o?void 0:l.toString();case f.BYTES:return p(t instanceof Uint8Array),t.byteLength?Re(t):o?"":void 0}}}function fe(r,e=V.STRING){switch(r){case f.BOOL:return!1;case f.UINT64:case f.FIXED64:return _(D.ZERO,e);case f.INT64:case f.SFIXED64:case f.SINT64:return _(E.ZERO,e);case f.DOUBLE:case f.FLOAT:return 0;case f.BYTES:return new Uint8Array(0);case f.STRING:return"";default:return 0}}class ut{constructor(e){this.info=e}prepare(){var e;if(!this.fieldNoToField){const t=(e=this.info.fields)!==null&&e!==void 0?e:[];this.fieldNoToField=new Map(t.map(n=>[n.no,n]))}}read(e,t,n,s){this.prepare();const i=s===void 0?e.len:e.pos+s;for(;e.post.no-n.no)}}write(e,t,n){this.prepare();for(const i of this.fields){let o,a,l=i.repeat,c=i.localName;if(i.oneof){const h=e[i.oneof];if(h.oneofKind!==c)continue;o=h[c],a=!0}else o=e[c],a=!1;switch(i.kind){case"scalar":case"enum":let h=i.kind=="enum"?f.INT32:i.T;if(l)if(p(Array.isArray(o)),l==ee.PACKED)this.packed(t,h,i.no,o);else for(const m of o)this.scalar(t,h,i.no,m,!0);else o===void 0?p(i.opt):this.scalar(t,h,i.no,o,a||i.opt);break;case"message":if(l){p(Array.isArray(o));for(const m of o)this.message(t,n,i.T(),i.no,m)}else this.message(t,n,i.T(),i.no,o);break;case"map":p(typeof o=="object"&&o!==null);for(const[m,g]of Object.entries(o))this.mapEntry(t,n,i,m,g);break}}let s=n.writeUnknownFields;s!==!1&&(s===!0?F.onWrite:s)(this.info.typeName,e,t)}mapEntry(e,t,n,s,i){e.tag(n.no,y.LengthDelimited),e.fork();let o=s;switch(n.K){case f.INT32:case f.FIXED32:case f.UINT32:case f.SFIXED32:case f.SINT32:o=Number.parseInt(s);break;case f.BOOL:p(s=="true"||s=="false"),o=s=="true";break}switch(this.scalar(e,n.K,1,o,!0),n.V.kind){case"scalar":this.scalar(e,n.V.T,2,i,!0);break;case"enum":this.scalar(e,f.INT32,2,i,!0);break;case"message":this.message(e,t,n.V.T(),2,i);break}e.join()}message(e,t,n,s,i){i!==void 0&&(n.internalBinaryWrite(i,e.tag(s,y.LengthDelimited).fork(),t),e.join())}scalar(e,t,n,s,i){let[o,a,l]=this.scalarInfo(t,s);(!l||i)&&(e.tag(n,o),e[a](s))}packed(e,t,n,s){if(!s.length)return;p(t!==f.BYTES&&t!==f.STRING),e.tag(n,y.LengthDelimited),e.fork();let[,i]=this.scalarInfo(t);for(let o=0;ogt(s,this)),this.options=n??{}}}class N extends Error{constructor(e,t="UNKNOWN",n){super(e),this.name="RpcError",Object.setPrototypeOf(this,new.target.prototype),this.code=t,this.meta=n??{}}toString(){const e=[this.name+": "+this.message];this.code&&(e.push(""),e.push("Code: "+this.code)),this.serviceName&&this.methodName&&e.push("Method: "+this.serviceName+"/"+this.methodName);let t=Object.entries(this.meta);if(t.length){e.push(""),e.push("Meta:");for(let[n,s]of t)e.push(` ${n}: ${s}`)}return e.join(` +`)}}function Nt(r,e){if(!e)return r;let t={};H(r,t),H(e,t);for(let n of Object.keys(e)){let s=e[n];switch(n){case"jsonOptions":t.jsonOptions=it(r.jsonOptions,t.jsonOptions);break;case"binaryOptions":t.binaryOptions=Je(r.binaryOptions,t.binaryOptions);break;case"meta":t.meta={},H(r.meta,t.meta),H(e.meta,t.meta);break;case"interceptors":t.interceptors=r.interceptors?r.interceptors.concat(s):s.concat();break}}return t}function H(r,e){if(!r)return;let t=e;for(let[n,s]of Object.entries(r))s instanceof Date?t[n]=new Date(s.getTime()):Array.isArray(s)?t[n]=s.concat():t[n]=s}var L;(function(r){r[r.PENDING=0]="PENDING",r[r.REJECTED=1]="REJECTED",r[r.RESOLVED=2]="RESOLVED"})(L||(L={}));class j{constructor(e=!0){this._state=L.PENDING,this._promise=new Promise((t,n)=>{this._resolve=t,this._reject=n}),e&&this._promise.catch(t=>{})}get state(){return this._state}get promise(){return this._promise}resolve(e){if(this.state!==L.PENDING)throw new Error(`cannot resolve ${L[this.state].toLowerCase()}`);this._resolve(e),this._state=L.RESOLVED}reject(e){if(this.state!==L.PENDING)throw new Error(`cannot reject ${L[this.state].toLowerCase()}`);this._reject(e),this._state=L.REJECTED}resolvePending(e){this._state===L.PENDING&&this.resolve(e)}rejectPending(e){this._state===L.PENDING&&this.reject(e)}}class wt{constructor(){this._lis={nxt:[],msg:[],err:[],cmp:[]},this._closed=!1,this._itState={q:[]}}onNext(e){return this.addLis(e,this._lis.nxt)}onMessage(e){return this.addLis(e,this._lis.msg)}onError(e){return this.addLis(e,this._lis.err)}onComplete(e){return this.addLis(e,this._lis.cmp)}addLis(e,t){return t.push(e),()=>{let n=t.indexOf(e);n>=0&&t.splice(n,1)}}clearLis(){for(let e of Object.values(this._lis))e.splice(0,e.length)}get closed(){return this._closed!==!1}notifyNext(e,t,n){p((e?1:0)+(t?1:0)+(n?1:0)<=1,"only one emission at a time"),e&&this.notifyMessage(e),t&&this.notifyError(t),n&&this.notifyComplete()}notifyMessage(e){p(!this.closed,"stream is closed"),this.pushIt({value:e,done:!1}),this._lis.msg.forEach(t=>t(e)),this._lis.nxt.forEach(t=>t(e,void 0,!1))}notifyError(e){p(!this.closed,"stream is closed"),this._closed=e,this.pushIt(e),this._lis.err.forEach(t=>t(e)),this._lis.nxt.forEach(t=>t(void 0,e,!1)),this.clearLis()}notifyComplete(){p(!this.closed,"stream is closed"),this._closed=!0,this.pushIt({value:null,done:!0}),this._lis.cmp.forEach(e=>e()),this._lis.nxt.forEach(e=>e(void 0,void 0,!0)),this.clearLis()}[Symbol.asyncIterator](){return this._closed===!0?this.pushIt({value:null,done:!0}):this._closed!==!1&&this.pushIt(this._closed),{next:()=>{let e=this._itState;p(e,"bad state"),p(!e.p,"iterator contract broken");let t=e.q.shift();return t?"value"in t?Promise.resolve(t):Promise.reject(t):(e.p=new j,e.p.promise)}}}pushIt(e){let t=this._itState;if(t.p){const n=t.p;p(n.state==L.PENDING,"iterator contract broken"),"value"in e?n.resolve(e):n.reject(e),delete t.p}else t.q.push(e)}}var yt=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};class Et{constructor(e,t,n,s,i,o,a){this.method=e,this.requestHeaders=t,this.request=n,this.headers=s,this.response=i,this.status=o,this.trailers=a}then(e,t){return this.promiseFinished().then(n=>e?Promise.resolve(e(n)):n,n=>t?Promise.resolve(t(n)):Promise.reject(n))}promiseFinished(){return yt(this,void 0,void 0,function*(){let[e,t,n,s]=yield Promise.all([this.headers,this.response,this.status,this.trailers]);return{method:this.method,requestHeaders:this.requestHeaders,request:this.request,headers:e,response:t,status:n,trailers:s}})}}var It=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};class Tt{constructor(e,t,n,s,i,o,a){this.method=e,this.requestHeaders=t,this.request=n,this.headers=s,this.responses=i,this.status=o,this.trailers=a}then(e,t){return this.promiseFinished().then(n=>e?Promise.resolve(e(n)):n,n=>t?Promise.resolve(t(n)):Promise.reject(n))}promiseFinished(){return It(this,void 0,void 0,function*(){let[e,t,n]=yield Promise.all([this.headers,this.status,this.trailers]);return{method:this.method,requestHeaders:this.requestHeaders,request:this.request,headers:e,status:t,trailers:n}})}}function ye(r,e,t,n,s){var i;{let o=(a,l,c)=>e.unary(a,l,c);for(const a of((i=n.interceptors)!==null&&i!==void 0?i:[]).filter(l=>l.interceptUnary).reverse()){const l=o;o=(c,h,m)=>a.interceptUnary(l,c,h,m)}return o(t,s,n)}}var u;(function(r){r[r.OK=0]="OK",r[r.CANCELLED=1]="CANCELLED",r[r.UNKNOWN=2]="UNKNOWN",r[r.INVALID_ARGUMENT=3]="INVALID_ARGUMENT",r[r.DEADLINE_EXCEEDED=4]="DEADLINE_EXCEEDED",r[r.NOT_FOUND=5]="NOT_FOUND",r[r.ALREADY_EXISTS=6]="ALREADY_EXISTS",r[r.PERMISSION_DENIED=7]="PERMISSION_DENIED",r[r.UNAUTHENTICATED=16]="UNAUTHENTICATED",r[r.RESOURCE_EXHAUSTED=8]="RESOURCE_EXHAUSTED",r[r.FAILED_PRECONDITION=9]="FAILED_PRECONDITION",r[r.ABORTED=10]="ABORTED",r[r.OUT_OF_RANGE=11]="OUT_OF_RANGE",r[r.UNIMPLEMENTED=12]="UNIMPLEMENTED",r[r.INTERNAL=13]="INTERNAL",r[r.UNAVAILABLE=14]="UNAVAILABLE",r[r.DATA_LOSS=15]="DATA_LOSS"})(u||(u={}));var Ot=function(r,e,t,n){function s(i){return i instanceof t?i:new t(function(o){o(i)})}return new(t||(t=Promise))(function(i,o){function a(h){try{c(n.next(h))}catch(m){o(m)}}function l(h){try{c(n.throw(h))}catch(m){o(m)}}function c(h){h.done?i(h.value):s(h.value).then(a,l)}c((n=n.apply(r,e||[])).next())})};function Ee(r,e,t,n,s){if(n)for(let[i,o]of Object.entries(n))if(typeof o=="string")r.append(i,o);else for(let a of o)r.append(i,a);if(r.set("Content-Type",e==="text"?"application/grpc-web-text":"application/grpc-web+proto"),e=="text"&&r.set("Accept","application/grpc-web-text"),r.set("X-Grpc-Web","1"),typeof t=="number"){if(t<=0)throw new N(`timeout ${t} ms exceeded`,u[u.DEADLINE_EXCEEDED]);r.set("grpc-timeout",`${t}m`)}else if(t){const i=t.getTime(),o=Date.now();if(i<=o)throw new N(`deadline ${t} exceeded`,u[u.DEADLINE_EXCEEDED]);r.set("grpc-timeout",`${i-o}m`)}return r}function Ie(r,e){let t=new Uint8Array(5+r.length);t[0]=S.DATA;for(let n=r.length,s=4;s>0;s--)t[s]=n%256,n>>>=8;return t.set(r,5),e==="binary"?t:Re(t)}function ue(r,e,t){if(arguments.length===1){let l=r,c;try{c=l.type}catch{}switch(c){case"error":case"opaque":case"opaqueredirect":throw new N(`fetch response type ${l.type}`,u[u.UNKNOWN])}return ue(Rt(l.headers),l.status,l.statusText)}let n=r,s=e>=200&&e<300,i=Pe(n),[o,a]=Ve(n);return(o===void 0||o===u.OK)&&!s&&(o=Bt(e),a=t),[o,a,i]}function Te(r){let e=Dt(r),[t,n]=Ve(e),s=Pe(e);return[t??u.OK,n,s]}var S;(function(r){r[r.DATA=0]="DATA",r[r.TRAILER=128]="TRAILER"})(S||(S={}));function Oe(r,e,t){return Ot(this,void 0,void 0,function*(){let n,s="",i=new Uint8Array(0),o=At(e);if(kt(r)){let a=r.getReader();n={next:()=>a.read()}}else n=r[Symbol.asyncIterator]();for(;;){let a=yield n.next();if(a.value!==void 0){if(o==="text"){for(let c=0;c=5&&i[0]===S.DATA;){let l=0;for(let c=1;c<5;c++)l=(l<<8)+i[c];if(i.length-5>=l)t(S.DATA,i.subarray(5,5+l)),i=i.subarray(5+l);else break}}if(a.done){if(i.length===0)break;if(i[0]!==S.TRAILER||i.length<5)throw new N("premature EOF",u[u.DATA_LOSS]);t(S.TRAILER,i.subarray(5));break}}})}const kt=r=>typeof r.getReader=="function";function ke(r,e){let t=new Uint8Array(r.length+e.length);return t.set(r),t.set(e,r.length),t}function At(r){switch(r){case"application/grpc-web-text":case"application/grpc-web-text+proto":return"text";case"application/grpc-web":case"application/grpc-web+proto":return"binary";case void 0:case null:throw new N("missing response content type",u[u.INTERNAL]);default:throw new N("unexpected response content type: "+r,u[u.INTERNAL])}}function Ve(r){let e,t,n=r["grpc-message"];if(n!==void 0){if(Array.isArray(n))return[u.INTERNAL,"invalid grpc-web message"];t=n}let s=r["grpc-status"];if(s!==void 0){if(Array.isArray(s))return[u.INTERNAL,"invalid grpc-web status"];if(e=parseInt(s,10),u[e]===void 0)return[u.INTERNAL,"invalid grpc-web status"]}return[e,t]}function Pe(r){let e={};for(let[t,n]of Object.entries(r))switch(t){case"grpc-message":case"grpc-status":case"content-type":break;default:e[t]=n}return e}function Dt(r){let e={};for(let t of String.fromCharCode.apply(String,r).trim().split(`\r +`)){if(t=="")continue;let[n,...s]=t.split(":");const i=s.join(":").trim();n=n.trim();let o=e[n];typeof o=="string"?e[n]=[o,i]:Array.isArray(o)?o.push(i):e[n]=i}return e}function Rt(r){let e={};return r.forEach((t,n)=>{let s=e[n];typeof s=="string"?e[n]=[s,t]:Array.isArray(s)?s.push(t):e[n]=t}),e}function Bt(r){switch(r){case 200:return u.OK;case 400:return u.INVALID_ARGUMENT;case 401:return u.UNAUTHENTICATED;case 403:return u.PERMISSION_DENIED;case 404:return u.NOT_FOUND;case 409:return u.ABORTED;case 412:return u.FAILED_PRECONDITION;case 429:return u.RESOURCE_EXHAUSTED;case 499:return u.CANCELLED;case 500:return u.UNKNOWN;case 501:return u.UNIMPLEMENTED;case 503:return u.UNAVAILABLE;case 504:return u.DEADLINE_EXCEEDED;default:return u.UNKNOWN}}class Lt{constructor(e){this.defaultOptions=e}mergeOptions(e){return Nt(this.defaultOptions,e)}makeUrl(e,t){let n=t.baseUrl;return n.endsWith("/")&&(n=n.substring(0,n.length-1)),`${n}/${e.service.typeName}/${e.name}`}clientStreaming(e){const t=new N("Client streaming is not supported by grpc-web",u[u.UNIMPLEMENTED]);throw t.methodName=e.name,t.serviceName=e.service.typeName,t}duplex(e){const t=new N("Duplex streaming is not supported by grpc-web",u[u.UNIMPLEMENTED]);throw t.methodName=e.name,t.serviceName=e.service.typeName,t}serverStreaming(e,t,n){var s,i,o,a,l;let c=n,h=(s=c.format)!==null&&s!==void 0?s:"text",m=(i=c.fetch)!==null&&i!==void 0?i:globalThis.fetch,g=(o=c.fetchInit)!==null&&o!==void 0?o:{},k=this.makeUrl(e,c),R=e.I.toBinary(t,c.binaryOptions),P=new j,A=new wt,U=!0,T,C=new j,x,K=new j;return m(k,Object.assign(Object.assign({},g),{method:"POST",headers:Ee(new globalThis.Headers,h,c.timeout,c.meta),body:Ie(R,h),signal:(a=n.abort)!==null&&a!==void 0?a:null})).then(b=>{let[d,I,B]=ue(b);if(P.resolve(B),d!=null&&d!==u.OK)throw new N(I??u[d],u[d],B);return d!=null&&(T={code:u[d],detail:I??u[d]}),b}).then(b=>{if(!b.body)throw new N("missing response body",u[u.INTERNAL]);return Oe(b.body,b.headers.get("content-type"),(d,I)=>{switch(d){case S.DATA:A.notifyMessage(e.O.fromBinary(I,c.binaryOptions)),U=!1;break;case S.TRAILER:let B,v;[B,v,x]=Te(I),T={code:u[B],detail:v??u[B]};break}})}).then(()=>{if(!x&&!U)throw new N("missing trailers",u[u.DATA_LOSS]);if(!T)throw new N("missing status",u[u.INTERNAL]);if(T.code!=="OK")throw new N(T.detail,T.code,x);A.notifyComplete(),C.resolve(T),K.resolve(x||{})}).catch(b=>{let d;b instanceof N?d=b:b instanceof Error&&b.name==="AbortError"?d=new N(b.message,u[u.CANCELLED]):d=new N(b instanceof Error?b.message:""+b,u[u.INTERNAL]),d.methodName=e.name,d.serviceName=e.service.typeName,P.rejectPending(d),A.notifyError(d),C.rejectPending(d),K.rejectPending(d)}),new Tt(e,(l=c.meta)!==null&&l!==void 0?l:{},t,P.promise,A,C.promise,K.promise)}unary(e,t,n){var s,i,o,a,l;let c=n,h=(s=c.format)!==null&&s!==void 0?s:"text",m=(i=c.fetch)!==null&&i!==void 0?i:globalThis.fetch,g=(o=c.fetchInit)!==null&&o!==void 0?o:{},k=this.makeUrl(e,c),R=e.I.toBinary(t,c.binaryOptions),P=new j,A,U=new j,T,C=new j,x,K=new j;return m(k,Object.assign(Object.assign({},g),{method:"POST",headers:Ee(new globalThis.Headers,h,c.timeout,c.meta),body:Ie(R,h),signal:(a=n.abort)!==null&&a!==void 0?a:null})).then(b=>{let[d,I,B]=ue(b);if(P.resolve(B),d!=null&&d!==u.OK)throw new N(I??u[d],u[d],B);return d!=null&&(T={code:u[d],detail:I??u[d]}),b}).then(b=>{if(!b.body)throw new N("missing response body",u[u.INTERNAL]);return Oe(b.body,b.headers.get("content-type"),(d,I)=>{switch(d){case S.DATA:if(A)throw new N("unary call received 2nd message",u[u.DATA_LOSS]);A=e.O.fromBinary(I,c.binaryOptions);break;case S.TRAILER:let B,v;[B,v,x]=Te(I),T={code:u[B],detail:v??u[B]};break}})}).then(()=>{if(!x&&A)throw new N("missing trailers",u[u.DATA_LOSS]);if(!T)throw new N("missing status",u[u.INTERNAL]);if(!A&&T.code==="OK")throw new N("expected error status",u[u.DATA_LOSS]);if(!A)throw new N(T.detail,T.code,x);if(U.resolve(A),T.code!=="OK")throw new N(T.detail,T.code,x);C.resolve(T),K.resolve(x||{})}).catch(b=>{let d;b instanceof N?d=b:b instanceof Error&&b.name==="AbortError"?d=new N(b.message,u[u.CANCELLED]):d=new N(b instanceof Error?b.message:""+b,u[u.INTERNAL]),d.methodName=e.name,d.serviceName=e.service.typeName,P.rejectPending(d),U.rejectPending(d),C.rejectPending(d),K.rejectPending(d)}),new Et(e,(l=c.meta)!==null&&l!==void 0?l:{},t,P.promise,U.promise,C.promise,K.promise)}}class Ft extends se{constructor(){super("grpcweb.example.v1.HelloRequest",[{no:1,name:"name",kind:"scalar",T:9}])}create(e){const t=globalThis.Object.create(this.messagePrototype);return t.name="",e!==void 0&&J(this,t,e),t}internalBinaryRead(e,t,n,s){let i=s??this.create(),o=e.pos+t;for(;e.pos50&&Z.shift(),ae.textContent=Z.join(` +`),ae.scrollTop=ae.scrollHeight}function M(r,e,t=!1){r.textContent=typeof e=="object"?JSON.stringify(e,null,2):String(e),r.className="result "+(t?"error":"success")}async function Xt(){const r=$e.value||"Anonymous";O(`[HTTP/JSON] 发送请求到 /v1/greeter/hello,name=${r}`);try{const t=await(await fetch("/v1/greeter/hello",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:r})})).json();O(`[HTTP/JSON] 收到响应: ${JSON.stringify(t)}`),M(te,t)}catch(e){const t=e instanceof Error?e.message:String(e);O(`[HTTP/JSON] 错误: ${t}`),M(te,t,!0)}}async function Wt(){const r=je.value||"Anonymous";O(`[HTTP/JSON] 发送请求到 /v1/greeter/goodbye,name=${r}`);try{const t=await(await fetch("/v1/greeter/goodbye",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:r})})).json();O(`[HTTP/JSON] 收到响应: ${JSON.stringify(t)}`),M(ne,t)}catch(e){const t=e instanceof Error?e.message:String(e);O(`[HTTP/JSON] 错误: ${t}`),M(ne,t,!0)}}async function Gt(){const r=$e.value||"Anonymous";O(`[gRPC Web] 调用 GreeterService.SayHello,name=${r}`);try{const e=ve.sayHello({name:r}),t=await e.response;O(`[gRPC Web] 收到响应: message=${t.message}, timestamp=${t.timestamp}`),M(te,{message:t.message,timestamp:t.timestamp.toString()});const n=await e.status,s=await e.headers,i=await e.trailers;O(`[gRPC Web] 状态: ${n.code}`),O(`[gRPC Web] Headers: ${JSON.stringify(Object.fromEntries(s))}`),O(`[gRPC Web] Trailers: ${JSON.stringify(Object.fromEntries(i))}`)}catch(e){const t=e instanceof Error?e.message:String(e);O(`[gRPC Web] 错误: ${t}`),M(te,t,!0)}}async function qt(){const r=je.value||"Anonymous";O(`[gRPC Web] 调用 GreeterService.SayGoodbye,name=${r}`);try{const e=ve.sayGoodbye({name:r}),t=await e.response;O(`[gRPC Web] 收到响应: message=${t.message}, timestamp=${t.timestamp}`),M(ne,{message:t.message,timestamp:t.timestamp.toString()});const n=await e.status;O(`[gRPC Web] 状态: ${n.code}`)}catch(e){const t=e instanceof Error?e.message:String(e);O(`[gRPC Web] 错误: ${t}`),M(ne,t,!0)}}Mt.addEventListener("click",Xt);Ct.addEventListener("click",Gt);Kt.addEventListener("click",Wt);Jt.addEventListener("click",qt);O("页面加载完成,protobuf-ts gRPC Web 客户端已初始化");O(`Transport: GrpcWebFetchTransport, baseUrl: ${window.location.origin}`); diff --git a/internal/examples/grpcweb/static/index.html b/internal/examples/grpcweb/static/index.html new file mode 100644 index 000000000..8e211ed7b --- /dev/null +++ b/internal/examples/grpcweb/static/index.html @@ -0,0 +1,168 @@ + + + + + + gRPC Web 测试 - protobuf-ts + + + + +

🌐 gRPC Web 测试工具 protobuf-ts

+ +
+ 说明: 本页面使用 @protobuf-ts/grpcweb-transport 实现 gRPC Web 调用。 +

+ HTTP/JSON 模式: 使用 application/json Content-Type,通过 REST API 调用 +
+ gRPC Web 模式: 使用 application/grpc-web+proto Content-Type,通过 protobuf-ts 客户端调用 +
+ +
+
+

👋 SayHello 测试

+ + + + +
等待测试...
+
+ +
+

👋 SayGoodbye 测试

+ + + + +
等待测试...
+
+ +
+

📋 请求日志

+
+
+
+ + + diff --git a/internal/examples/httpserver/config.yaml b/internal/examples/httpserver/config.yaml new file mode 100644 index 000000000..302618ba7 --- /dev/null +++ b/internal/examples/httpserver/config.yaml @@ -0,0 +1,15 @@ +app: + env: "dev" + +logger: + level: "info" + as_json: false + +metric: + driver: "prometheus" + interval: 5s + +http_server: + base_url: /api + enable_print_router: true + # http_port: 8080 diff --git a/internal/examples/httpserver/main.go b/internal/examples/httpserver/main.go new file mode 100644 index 000000000..cc02dc2c0 --- /dev/null +++ b/internal/examples/httpserver/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + + "github.com/gofiber/fiber/v3" + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/recovery" + + "github.com/pubgo/lava/v2/core/lavabuilder" + "github.com/pubgo/lava/v2/core/logging" + _ "github.com/pubgo/lava/v2/core/logging/logext/slog" + "github.com/pubgo/lava/v2/core/metrics" + "github.com/pubgo/lava/v2/lava" + "github.com/pubgo/lava/v2/servers/https" +) + +var _ lava.HttpRouter = (*helloRouter)(nil) + +type helloRouter struct{} + +func (h *helloRouter) Prefix() string { return "/api" } + +func (h *helloRouter) Middlewares() []lava.Middleware { return nil } + +func (h *helloRouter) Router(router fiber.Router) { + router.Get("/ping", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "pong", + "service": "http-only", + }) + }) + + router.Get("/hello/:name", func(c fiber.Ctx) error { + name := c.Params("name") + if name == "" { + name = "world" + } + + return c.SendString(fmt.Sprintf("hello, %s", name)) + }) +} + +func main() { + defer recovery.Exit() + + version.SetVersion("v1.0.0") + version.SetProject("httpserver-example") + + env.Reload() + env.LoadFiles(".env").Must() + + config.SetConfigPath("internal/examples/httpserver/config.yaml") + + builder := lavabuilder.New() + builder.Provide(config.Load[Config]) + builder.Provide(func() lava.HttpRouter { return &helloRouter{} }) + + // 运行纯 HTTP 服务: + // go run ./internal/examples/httpserver http + // 注意:这里没有注册任何 gRPC router,因此使用 http 命令时只会启动 HTTP 服务。 + lavabuilder.Run(builder) +} + +type Config struct { + metrics.MetricConfigLoader `yaml:",inline"` + logging.LogConfigLoader `yaml:",inline"` + https.HttpServerConfigLoader `yaml:",inline"` +} diff --git a/internal/examples/scheduler/main.go b/internal/examples/scheduler/main.go index 4f7d93ac7..88b0bd821 100644 --- a/internal/examples/scheduler/main.go +++ b/internal/examples/scheduler/main.go @@ -2,19 +2,25 @@ package main import ( "context" - "fmt" + "log/slog" "time" - "github.com/pubgo/funk/v2/cmds/configcmd" - "github.com/pubgo/funk/v2/cmds/envcmd" + "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/debugs" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" + "github.com/pubgo/funk/v2/result/resultchecker" + "github.com/pubgo/lava/v2/cmds/configcmd" + "github.com/pubgo/lava/v2/cmds/envcmd" "github.com/pubgo/lava/v2/core/lavabuilder" "github.com/pubgo/lava/v2/core/logging" "github.com/pubgo/lava/v2/core/metrics" "github.com/pubgo/lava/v2/core/scheduler" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" "github.com/pubgo/lava/v2/servers/https" ) @@ -30,19 +36,20 @@ type schedulerExample struct{} func (s schedulerExample) RegisterSchedulerJob(reg scheduler.JobRegistry) { reg.Once("once_task", time.Second*10, func(ctx context.Context, name string, metadata *scheduler.JobMetadata) result.Result[[]byte] { - fmt.Printf("exec once task: %s: %#v\n", name, metadata) + slog.Info("register once task", "name", name, "metadata", metadata) time.Sleep(time.Second * 5) return result.OK([]byte("once")) }) reg.Every("every_task", time.Second*5, func(ctx context.Context, name string, metadata *scheduler.JobMetadata) result.Result[[]byte] { - fmt.Printf("exec every task: %s: %#v\n", name, metadata) + slog.Info("register every task", "name", name, "metadata", metadata) + slog.Info("debugs enabled", "enabled", debugs.Enabled.String()) time.Sleep(time.Second * 1) return result.OK([]byte("every")) }) reg.Cron("cron_task", "*/7 * * * * *", func(ctx context.Context, name string, metadata *scheduler.JobMetadata) result.Result[[]byte] { - fmt.Printf("exec cron task: %s: %#v\n", name, metadata) + slog.Info("exec cron task", "name", name, "metadata", metadata) time.Sleep(time.Second * 2) return result.OK([]byte("cron")) }) @@ -51,6 +58,19 @@ func (s schedulerExample) RegisterSchedulerJob(reg scheduler.JobRegistry) { func main() { defer recovery.Exit() + version.SetVersion("v1.0.0") + version.SetProject("scheduler") + config.SetConfigPath("internal/configs/scheduler.yaml") + resultchecker.RegisterErrCheck(log.RecordErr()) + log.SetEnableChecker(func(ctx context.Context, lvl log.Level, name, message string, fields log.Fields) bool { + //if lvl == zerolog.DebugLevel { + // return false + //} + return true + }) + // debugs.SetEnabled() + env.LoadFiles(".env").Must() + builder := lavabuilder.New() builder.Provide(config.Load[Config]) builder.Provide(envcmd.New) diff --git a/internal/examples/scheduler/taskfile.yml b/internal/examples/scheduler/taskfile.yml index 34ac11d0a..40428d8b8 100644 --- a/internal/examples/scheduler/taskfile.yml +++ b/internal/examples/scheduler/taskfile.yml @@ -23,4 +23,5 @@ tasks: run: cmds: - task scheduler:build - - SERVER_HTTP_PORT=8082 ./bin/scheduler scheduler -c ./internal/configs/scheduler.yaml + - kill -9 $(ps -ef | grep scheduler.yaml | grep -v grep | awk '{print $2}') || true + - SERVER_HTTP_PORT=8082 ./bin/scheduler cron -c ./internal/configs/scheduler.yaml diff --git a/internal/examples/tunnel/README.md b/internal/examples/tunnel/README.md new file mode 100644 index 000000000..1f4a6a60c --- /dev/null +++ b/internal/examples/tunnel/README.md @@ -0,0 +1,121 @@ +# Tunnel Gateway Example + +这是一个独立运行的 Tunnel Gateway 服务示例。 + +## 架构说明 + +``` + 外部请求 + │ + ▼ +┌───────────────────────────────────────────────────┐ +│ Tunnel Gateway (本示例) │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ +│ │ HTTP :8888 │ │ gRPC :9999 │ │Debug :6066│ │ +│ └──────┬──────┘ └──────┬──────┘ └─────┬─────┘ │ +│ └────────────────┼───────────────┘ │ +│ │ │ +│ Tunnel Listener :7007 │ +│ Admin UI :6067 │ +└──────────────────────────┬───────────────────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ┌──────┴──────┐ ┌─────┴─────┐ ┌──────┴──────┐ + │ Scheduler │ │ Service B │ │ Service C │ + │ (Agent) │ │ (Agent) │ │ (Agent) │ + └─────────────┘ └───────────┘ └─────────────┘ + 内网服务节点(主动连接 Gateway) +``` + +## 运行 + +### 1. 启动 Gateway + +```bash +# 使用 task +task tunnel:run + +# 或手动运行 +go build -o ./bin/tunnel-gateway ./internal/examples/tunnel/main.go +./bin/tunnel-gateway tunnel -c ./internal/configs/tunnel.yaml +``` + +Gateway 将监听以下端口: +- `:7007` - 接受 Agent 连接 +- `:8888` - HTTP 代理(对外暴露服务) +- `:9999` - gRPC 代理 +- `:6066` - Debug 代理(代理到各服务的 debug 接口) +- `:6067` - 管理界面(Gateway 自身的管理 UI) + +### 2. 访问管理界面 + +打开浏览器访问 `http://localhost:6067/debug/tunnel` 查看: +- 已注册的服务列表 +- 各服务的端点信息 +- 服务状态和元数据 + +### 3. 启动带 Agent 的 Scheduler 服务 + +```bash +# 在另一个终端 +TUNNEL_GATEWAY_ADDR=localhost:7007 ./bin/scheduler scheduler -c ./internal/configs/scheduler.yaml +``` + +Scheduler 服务会通过 Agent 连接到 Gateway,注册自己。 + +### 4. 通过 Gateway 访问服务 + +```bash +# 访问 scheduler 服务的接口 +curl http://localhost:8888/scheduler/api/v1/jobs + +# 访问 scheduler 服务的 debug 接口 +curl http://localhost:6066/scheduler/debug/pprof/ + +# 获取服务列表 API +curl http://localhost:6067/debug/tunnel/api/services +``` + +## 配置 + +配置文件位于 `internal/configs/` 目录下,复用项目统一的配置结构: + +``` +internal/configs/ +├── tunnel.yaml # Tunnel 主配置 +├── scheduler.yaml # Scheduler 主配置 +├── components/ +│ ├── tunnel.yaml # Tunnel Gateway 组件配置 +│ ├── http_server.yaml # HTTP 服务配置 +│ ├── logger.yaml # 日志配置 +│ └── metric.yaml # 指标配置 +└── envs/ + └── .env # 环境变量 +``` + +### Gateway 配置 (components/tunnel.yaml) + +```yaml +tunnel: + listen_addr: ":7007" # Agent 连接地址 + http_port: 8888 # HTTP 代理端口 + grpc_port: 9999 # gRPC 代理端口 + debug_port: 6066 # Debug 代理端口 +``` + +### Agent 配置(环境变量) + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `TUNNEL_GATEWAY_ADDR` | Gateway 地址 | `localhost:7000` | +| `HTTP_ADDR` | 本地 HTTP 服务地址 | `localhost:8080` | +| `DEBUG_ADDR` | 本地 Debug 服务地址 | `localhost:6060` | + +## 使用场景 + +1. **内网服务暴露**:服务在内网/防火墙后,通过 Agent 主动连接 Gateway 暴露到公网 +2. **服务聚合**:多个微服务通过同一个 Gateway 统一入口 +3. **远程调试**:通过 Gateway 访问内网服务的 pprof/debug 接口 +4. **零配置部署**:服务只需知道 Gateway 地址,无需开放端口 diff --git a/internal/examples/tunnel/main.go b/internal/examples/tunnel/main.go new file mode 100644 index 000000000..a5d736422 --- /dev/null +++ b/internal/examples/tunnel/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/recovery" + + "github.com/pubgo/lava/v2/core/lavabuilder" +) + +func main() { + defer recovery.Exit() + + version.SetVersion("v1.0.0") + version.SetProject("tunnel-gateway") + config.SetConfigPath("internal/configs/tunnel.yaml") + env.LoadFiles(".env").Must() + + builder := lavabuilder.New() + lavabuilder.Run(builder) +} diff --git a/internal/examples/tunnel/taskfile.yml b/internal/examples/tunnel/taskfile.yml new file mode 100644 index 000000000..61c8be525 --- /dev/null +++ b/internal/examples/tunnel/taskfile.yml @@ -0,0 +1,49 @@ +# https://taskfile.dev + +version: '3' + +vars: + TunnelRelease: "v0.1.0" + TunnelProject: "tunnel-gateway" + +tasks: + info: + cmds: + - echo "{{.TunnelRelease}}" "{{.TunnelProject}}" + + default: + cmds: + - task tunnel:info + + build: + cmds: + - go build -o ./bin/tunnel-gateway ./internal/examples/tunnel/main.go + - ls -alh ./bin + + run: + desc: "启动 Tunnel Gateway" + cmds: + - task tunnel:build + - kill -9 $(ps -ef | grep tunnel-gateway | grep -v grep | awk '{print $2}') || true + - ./bin/tunnel-gateway tunnel -c ./internal/configs/tunnel.yaml + + run-foreground: + desc: "仅启动 Gateway(前台运行)" + cmds: + - task tunnel:build + - ./bin/tunnel-gateway tunnel -c ./internal/configs/tunnel.yaml + + test: + desc: "运行 tunnel 模块测试" + cmds: + - go test -v ./core/tunnel/... -timeout 30s + + test-integration: + desc: "运行集成测试" + cmds: + - go test -v ./cmds/schedulercmd/... -run TestTunnelIntegration -timeout 30s + + clean: + desc: "清理构建产物" + cmds: + - rm -f ./bin/tunnel-gateway diff --git a/internal/middlewares/middleware_accesslog/middleware.go b/internal/middlewares/middleware_accesslog/middleware.go index 542acda80..23799713d 100644 --- a/internal/middlewares/middleware_accesslog/middleware.go +++ b/internal/middlewares/middleware_accesslog/middleware.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/gofiber/utils" + "github.com/gofiber/utils/v2" "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/convert" "github.com/pubgo/funk/v2/errors/errcode" diff --git a/internal/middlewares/middleware_serviceinfo/middleware.go b/internal/middlewares/middleware_serviceinfo/middleware.go index 187a58a94..ade32e3a3 100644 --- a/internal/middlewares/middleware_serviceinfo/middleware.go +++ b/internal/middlewares/middleware_serviceinfo/middleware.go @@ -5,11 +5,11 @@ import ( "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/convert" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/funk/v2/strutil" "github.com/rs/xid" "github.com/pubgo/lava/v2/core/lavacontexts" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/lava" "github.com/pubgo/lava/v2/pkg/grpcutil" "github.com/pubgo/lava/v2/pkg/httputil" diff --git a/lava/router.go b/lava/router.go index 467672abb..ee867b74e 100644 --- a/lava/router.go +++ b/lava/router.go @@ -1,7 +1,7 @@ package lava import ( - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "google.golang.org/grpc" ) diff --git a/main.go b/main.go new file mode 100644 index 000000000..df1474ff7 --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + + "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/redant" + + "github.com/pubgo/lava/v2/cmds/curlcmd" + "github.com/pubgo/lava/v2/cmds/devproxycmd" + "github.com/pubgo/lava/v2/cmds/fileservercmd" + "github.com/pubgo/lava/v2/cmds/tunnelcmd" + "github.com/pubgo/lava/v2/cmds/watchcmd" + "github.com/pubgo/lava/v2/core/lavabuilder" + "github.com/pubgo/lava/v2/core/signals" + "github.com/pubgo/lava/v2/pkg/cliutil" +) + +func main() { + defer recovery.Exit() + + // 创建主命令 + di := lavabuilder.New() + app := &redant.Command{ + Use: "lava", + Short: cliutil.UsageDesc("%s service", version.Project()), + Long: "Lava is a microservice integration framework", + Handler: func(ctx context.Context, i *redant.Invocation) error { + fmt.Println("Usage: lava [command] [arguments]") + fmt.Println("Available commands:") + fmt.Println(" watch Watch files for changes and run commands automatically") + fmt.Println(" curl Make HTTP requests to gRPC services") + fmt.Println(" tunnel Tunnel gateway commands") + fmt.Println(" devproxy Local development proxy tool") + return nil + }, + // 添加子命令 + Children: []*redant.Command{ + watchcmd.New(), + curlcmd.New(), + tunnelcmd.New(di), + fileservercmd.New(), + devproxycmd.New(di), + }, + } + + // 运行命令 + assert.Exit(app.Run(signals.Context())) +} diff --git a/pkg/fiberbuilder/config.go b/pkg/fiberbuilder/config.go index 461e3e957..ec51856fc 100644 --- a/pkg/fiberbuilder/config.go +++ b/pkg/fiberbuilder/config.go @@ -5,7 +5,7 @@ import ( "time" "dario.cat/mergo" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2/result" "github.com/samber/lo" @@ -13,7 +13,6 @@ import ( ) type Config struct { - Prefork bool `yaml:"-"` ServerHeader string `yaml:"-"` CaseSensitive bool `yaml:"-"` Immutable bool `yaml:"-"` @@ -68,20 +67,26 @@ type Config struct { // Optional. Default: false EnableSplittingOnParsers bool `yaml:"enable_splitting_on_parsers"` - ETag bool `yaml:"etag"` - ReadTimeout time.Duration `yaml:"read_timeout"` - WriteTimeout time.Duration `yaml:"write_timeout"` - IdleTimeout time.Duration `yaml:"idle_timeout"` - ReadBufferSize int `yaml:"read_buffer_size"` - WriteBufferSize int `yaml:"write_buffer_size"` - CompressedFileSuffix string `yaml:"compressed_file_suffix"` - DisableHeaderNormalizing bool `yaml:"disable_header_normalizing"` - DisableStartupMessage bool `yaml:"disable_startup_message"` + ETag bool `yaml:"etag"` + ReadTimeout time.Duration `yaml:"read_timeout"` + WriteTimeout time.Duration `yaml:"write_timeout"` + IdleTimeout time.Duration `yaml:"idle_timeout"` + ReadBufferSize int `yaml:"read_buffer_size"` + WriteBufferSize int `yaml:"write_buffer_size"` + CompressedFileSuffix string `yaml:"compressed_file_suffix"` + CompressedFileSuffixes map[string]string `yaml:"compressed_file_suffixes"` + DisableHeaderNormalizing bool `yaml:"disable_header_normalizing"` } func (t *Config) ToCfg() fiber.Config { + compressed := t.CompressedFileSuffixes + if compressed == nil && t.CompressedFileSuffix != "" { + compressed = map[string]string{ + "gzip": t.CompressedFileSuffix, + } + } + return fiber.Config{ - Prefork: t.Prefork, ServerHeader: t.ServerHeader, CaseSensitive: t.CaseSensitive, Immutable: t.Immutable, @@ -98,17 +103,14 @@ func (t *Config) ToCfg() fiber.Config { DisablePreParseMultipartForm: t.DisablePreParseMultipartForm, ReduceMemoryUsage: t.ReduceMemoryUsage, EnableIPValidation: t.EnableIPValidation, - EnablePrintRoutes: t.EnablePrintRoutes, EnableSplittingOnParsers: t.EnableSplittingOnParsers, - ETag: t.ETag, ReadTimeout: t.ReadTimeout, WriteTimeout: t.WriteTimeout, IdleTimeout: t.IdleTimeout, ReadBufferSize: t.ReadBufferSize, WriteBufferSize: t.WriteBufferSize, - CompressedFileSuffix: t.CompressedFileSuffix, + CompressedFileSuffixes: compressed, DisableHeaderNormalizing: t.DisableHeaderNormalizing, - DisableStartupMessage: t.DisableStartupMessage, JSONEncoder: protojson.Default.Marshal, } } diff --git a/pkg/gateway/README.md b/pkg/gateway/README.md index 2efade2b2..44c5f099c 100644 --- a/pkg/gateway/README.md +++ b/pkg/gateway/README.md @@ -2,723 +2,24 @@ Gateway 是一个 gRPC Gateway 实现,提供 HTTP/JSON 到 gRPC 的协议转换功能。它支持服务注册、中间件、HTTP Rule 解析等特性,让开发者可以轻松地将 gRPC 服务暴露为 RESTful API。 -## 概述 - -Gateway 模块实现了完整的 gRPC Gateway 功能,允许客户端通过 HTTP/JSON 调用 gRPC 服务。它基于 [Google API HTTP Annotation](https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api#google.api.DocumentationRule.FIELDS.string.google.api.DocumentationRule.selector) 规范,支持灵活的路径模板和请求/响应体映射。 - -### 核心特性 +## 核心特性 - **HTTP Rule 解析**:支持 `google.api.http` 注解,自动解析 RESTful 路径模板 - **协议转换**:自动处理 HTTP/JSON 与 gRPC/Protobuf 之间的双向转换 +- **gRPC Web 支持**:允许浏览器直接调用 gRPC 服务 - **服务注册**:支持本地服务和代理服务的注册 - **中间件支持**:提供 Unary 和 Stream 拦截器 - **自定义编解码**:支持 JSON、Protobuf 等多种编码格式 -- **请求/响应拦截器**:支持针对特定消息类型的自定义编解码逻辑 - **错误映射**:自动将 gRPC 错误码映射为 HTTP 状态码 -- **流式支持**:支持 HTTP、WebSocket、in-process、proxy 等多种流类型 - -### 模块结构 - -``` -pkg/gateway/ -├── mux.go # 核心路由器 Mux,实现 Gateway 接口 -├── routertree/ # 路由树实现,负责路径匹配 -│ ├── router.go # 路由树核心逻辑 -│ ├── parser.go # HTTP Rule 路径模板解析器 -│ └── lex.go # 词法分析器 -├── codec.go # 编解码器接口和实现(JSON、Protobuf) -├── stream.go # Stream 接口定义 -├── stream.http.go # HTTP 流实现 -├── stream.websocket.go # WebSocket 流实现 -├── stream.inprocess.go # 进程内流实现 -├── stream.proxy.go # 代理流实现 -├── context.go # 上下文和元数据管理 -├── util.go # 工具函数(HTTP Rule 解析、元数据转换等) -├── fieldmask.go # FieldMask 支持 -├── wrapper.go # 服务和方法包装器 -├── grpccodes.go # gRPC 错误码到 HTTP 状态码映射 -├── gatewayutils/ # Gateway 工具函数 -│ ├── query_params.go # 查询参数处理 -│ └── trie.go # Trie 树实现 -└── internal/ # 内部实现(压缩器等) -``` - -## 架构设计 - -### 设计理念 - -Gateway 模块基于 **Google API HTTP Annotation** 规范,实现了从 HTTP/REST 到 gRPC 的透明转换。其设计遵循以下原则: - -1. **声明式路由**:通过 Protobuf 注解定义 HTTP 路由,无需手动编写路由代码 -2. **协议透明**:客户端使用标准的 HTTP/JSON,后端使用 gRPC,Gateway 自动处理转换 -3. **类型安全**:基于 Protobuf 的类型系统,保证请求/响应的类型安全 -4. **可扩展性**:支持自定义编解码器、拦截器、压缩器等扩展点 - -### 核心组件 - -``` -┌─────────────────────────────────────────────────────────┐ -│ Gateway (Mux) │ -│ 核心路由器,管理整个请求生命周期 │ -├─────────────────────────────────────────────────────────┤ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ RouterTree │ │ Codec │ │ Stream │ │ -│ │ (路径匹配) │ │ (编解码) │ │ (流处理) │ │ -│ │ │ │ │ │ │ │ -│ │ - 路径解析 │ │ - JSON │ │ - HTTP │ │ -│ │ - 变量提取 │ │ - Protobuf │ │ - WebSocket │ │ -│ │ - 路由匹配 │ │ - 自定义 │ │ - InProcess │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Context │ │ FieldMask │ │ Wrapper │ │ -│ │ (元数据管理) │ │ (字段掩码) │ │ (服务包装) │ │ -│ │ │ │ │ │ │ │ -│ │ - HTTP↔gRPC │ │ - 字段过滤 │ │ - 本地服务 │ │ -│ │ - Metadata │ │ - 路径解析 │ │ - 代理服务 │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### 请求处理流程 - -完整的请求处理流程如下: - -``` -HTTP Request (JSON) - │ - ├─> [1. Handler] 接收 Fiber Context - │ - ├─> [2. RouterTree.Match] - │ ├─ 解析 HTTP 方法和路径 - │ ├─ 匹配路由规则(支持通配符、变量、动词) - │ └─ 提取路径变量和查询参数 - │ - ├─> [3. Method Lookup] - │ └─ 根据 gRPC 方法名查找对应的 methodWrapper - │ - ├─> [4. Metadata Conversion] - │ └─ 将 HTTP 头转换为 gRPC metadata(支持二进制编码) - │ - ├─> [5. streamHTTP.RecvMsg] - │ ├─ 根据 body 规则解析请求体(JSON → Protobuf) - │ ├─ 合并路径变量和查询参数到消息字段 - │ └─ 执行请求拦截器(如果配置) - │ - ├─> [6. Mux.Invoke] - │ ├─ 判断是本地服务还是代理服务 - │ ├─ 本地服务:使用 inprocgrpc.Channel 调用 - │ └─ 代理服务:转发到远程 gRPC 客户端 - │ - ├─> [7. gRPC Service Execution] - │ ├─ 执行 Unary/Stream 拦截器 - │ └─ 调用实际的 gRPC 服务方法 - │ - ├─> [8. streamHTTP.SendMsg] - │ ├─ 根据 response_body 规则定位响应字段(通过 getRspBodyDesc) - │ ├─ 执行响应编码器(如果配置) - │ └─ Protobuf → JSON 编码并写入响应 - │ - ├─> [9. Metadata Conversion] - │ └─ 将 gRPC header/trailer 转换为 HTTP 响应头 - │ - └─> [10. HTTP Response] 返回 JSON 响应 -``` - -### 关键数据结构 -#### 1. Mux(核心路由器) +## 快速开始 -`Mux` 是 Gateway 的核心结构,实现了 `Gateway` 接口和 `grpc.ClientConnInterface`: - -**主要字段:** -- `localClient`: 进程内 gRPC 通道(`inprocgrpc.Channel`),用于调用本地服务 -- `routerTree`: 路由树,存储和匹配 HTTP 路由规则 -- `opts`: 配置选项,包括编解码器、拦截器、压缩器等 - -**核心职责:** -- 管理服务注册(本地服务和代理服务) -- 路由匹配和方法查找 -- 处理 HTTP 请求到 gRPC 调用的转换 -- 管理编解码器和压缩器 -- 提供拦截器支持(Unary 和 Stream) - -#### 2. RouterTree(路由树) - -`RouterTree` 基于树形结构实现高效的路径匹配: - -**数据结构:** -```go -type RouteTree struct { - nodeMap map[string]*nodeTree // 按 HTTP 方法分组的节点树 -} - -type nodeTree struct { - nodeMap map[string]*nodeTree // 子节点(路径段) - verbMap map[string]*routeTarget // 动词到路由目标的映射 -} -``` - -**特性:** -- **路径解析**:使用 `participle` 解析 HTTP Rule 路径模板 -- **变量提取**:支持路径变量(`{field}`)、带模式的变量(`{field=pattern}`) -- **通配符匹配**:支持 `*`(单段)和 `**`(多段贪婪匹配) -- **动词支持**:支持 `:verb` 后缀用于区分操作 -- **多路由绑定**:支持 `additional_bindings` 同一方法映射到多个 HTTP 路径 - -**匹配算法:** -1. 按 HTTP 方法分组路由树(通过 `handlerMethod` 转换方法名) -2. 逐段匹配路径,支持精确匹配、路径变量和通配符匹配 -3. 提取路径变量并映射到 Protobuf 字段路径 -4. 匹配动词(如果有)确定最终路由 - -**匹配规则验证:** -- ✅ 路由重复注册检查:防止意外覆盖已有路由 -- ✅ 路径变量边界检查:防止数组越界 -- ✅ 空路径验证:确保路由路径有效 -- ✅ 通配符正确处理:`*` 匹配单段,`**` 贪婪匹配多段 - -#### 3. Codec(编解码器) - -Gateway 支持多种编解码器: - -**内置编解码器:** -- `CodecJSON`:基于 `protojson` 的 JSON 编解码,支持 Protobuf 与 JSON 的双向转换 -- `CodecProto`:Protobuf 二进制格式编解码 -- `codecHTTPBody`:原始 HTTP Body 处理(用于 `google.api.HttpBody` 类型) - -**编解码器接口:** -```go -type Codec interface { - encoding.Codec - MarshalAppend([]byte, any) ([]byte, error) // 追加式序列化 -} - -type StreamCodec interface { - Codec - ReadNext(buf []byte, r io.Reader, limit int) (dst []byte, n int, err error) - WriteNext(w io.Writer, src []byte) (n int, err error) -} -``` - -#### 4. Stream(流处理) - -Gateway 实现了 `grpc.ServerStream` 接口,支持不同类型的流式 RPC: - -**流类型:** -- `streamHTTP`:基于 Fiber Context 的 HTTP 请求/响应流 -- `streamWebSocket`:WebSocket 双向流 -- `streamInProcess`:进程内流(用于本地服务调用) -- `streamProxy`:代理流(转发到远程 gRPC 服务) - -**关键方法:** -- `RecvMsg`: 从 HTTP 请求中接收并解码消息 -- `SendMsg`: 编码并发送消息到 HTTP 响应 -- `SetHeader/SendHeader`: 设置 HTTP 响应头 -- `SetTrailer`: 设置 HTTP trailer - -#### 5. ServiceWrapper 和 MethodWrapper(服务包装) - -**ServiceWrapper:** -- 包装 gRPC 服务描述符和实现 -- 区分本地服务和代理服务 -- 管理服务的编解码器和拦截器配置 - -**MethodWrapper:** -- 包装单个 gRPC 方法 -- 存储输入/输出消息类型 -- 关联 HTTP Rule 配置和路径变量信息 -- 支持自定义操作名称(通过 `RpcMeta` 扩展) - -#### 6. Context(上下文管理) - -Gateway 提供了丰富的上下文管理功能: - -**元数据转换:** -- HTTP 请求头 → gRPC metadata -- gRPC header/trailer → HTTP 响应头 -- 支持二进制元数据(通过 `-bin` 后缀和 base64 编码) - -**保留的 HTTP 头:** -- `Content-Type`, `User-Agent` -- `grpc-*` 相关的头(如 `grpc-status`, `grpc-message` 等) - -**上下文扩展:** -- `RPCMethod`: 从上下文获取 gRPC 方法名 -- `HTTPPathPattern`: 获取匹配的 HTTP 路径模板 -- `ServerMetadata`: 传递 gRPC 服务的 header 和 trailer - -#### 7. FieldMask 支持 - -Gateway 支持 Google FieldMask 协议,允许客户端指定要返回的字段: - -**功能:** -- `FieldMaskFromRequestBody`: 从 JSON 请求体自动生成 FieldMask -- 支持嵌套字段路径(如 `user.profile.name`) -- 自动处理动态消息类型(`google.protobuf.Struct`) -- 支持 `google.protobuf.Any` 类型的字段过滤 - -**使用场景:** -- 部分字段更新(PATCH 请求) -- 减少响应数据量(客户端只请求需要的字段) -- 支持 GraphQL 风格的字段选择 - -## 主要功能 - -### 1. 服务注册 - -Gateway 支持两种服务注册方式: - -#### 注册本地服务 - -本地服务运行在同一个进程中,通过 `inprocgrpc.Channel` 进行进程内通信,避免了网络开销: - -```go -mux := gateway.NewMux() -mux.RegisterService(&pb.UserService_ServiceDesc, &userServiceImpl{}) -``` - -**注册流程:** -1. 验证服务实现是否满足接口要求 -2. 注册到进程内 gRPC 通道(`inprocgrpc.Channel`) -3. 解析 Protobuf 服务描述符,提取方法定义 -4. 解析每个方法的 HTTP Rule 注解 -5. 构建路由树,注册 HTTP 路径到 gRPC 方法的映射 -6. 处理 `additional_bindings`,支持多个 HTTP 路径映射到同一方法 - -#### 注册代理服务 - -代理服务将请求转发到远程 gRPC 服务,适用于微服务架构: - -```go -// 创建 GrpcRouter 实现(用于提供中间件和服务描述符) -type userServiceProxy struct{} - -func (p *userServiceProxy) ServiceDesc() *grpc.ServiceDesc { - return &pb.UserService_ServiceDesc -} - -func (p *userServiceProxy) Middlewares() []lava.Middleware { - return []lava.Middleware{ - // 可以添加中间件,如认证、日志等 - } -} - -// 连接到远程 gRPC 服务(可以配置负载均衡、服务发现等) -conn, _ := grpc.Dial( - "remote-service:50051", - grpc.WithInsecure(), - // 可以添加更多选项,如负载均衡、超时等 -) - -// 注册代理服务 -proxy := &userServiceProxy{} -mux.RegisterProxy(&pb.UserService_ServiceDesc, proxy, conn) -``` - -**代理模式特点:** -- 使用 `grpc.ClientConnInterface` 连接到远程 gRPC 服务 -- 可以配置不同的连接策略(负载均衡、服务发现等) -- 保持 HTTP/JSON 接口的统一性 -- 支持多实例负载均衡 - -**GrpcRouter 接口说明:** -`lava.GrpcRouter` 接口提供了 `Middlewares()` 和 `ServiceDesc()` 方法,用于配置服务的中间件和获取服务描述符。实际的代理路由由 `grpc.ClientConnInterface` 管理,可以通过配置 gRPC 客户端连接来实现负载均衡、服务发现等功能。 - -### 2. HTTP Rule 配置 - -在 Protobuf 定义中使用 `google.api.http` 注解: - -```protobuf -service UserService { - rpc GetUser(GetUserRequest) returns (User) { - option (google.api.http) = { - get: "/v1/users/{user_id}" - body: "*" - }; - } - - rpc CreateUser(CreateUserRequest) returns (User) { - option (google.api.http) = { - post: "/v1/users" - body: "user" - }; - } - - rpc UpdateUser(UpdateUserRequest) returns (User) { - option (google.api.http) = { - patch: "/v1/users/{user.id}" - body: "user" - additional_bindings: { - put: "/v1/users/{user.id}" - body: "user" - } - }; - } -} -``` - -### 3. 请求/响应体映射 - -HTTP Rule 中的 `body` 和 `response_body` 字段控制请求和响应消息的映射方式: - -#### 请求体映射(`body`) - -- `"*"` 或 `""`:整个请求消息作为请求体,所有字段都从 JSON body 中解析 -- `"field_name"`:仅指定字段作为请求体,其他字段从路径变量或查询参数中获取 - -**示例:** -```protobuf -message UpdateUserRequest { - string user_id = 1; // 从路径变量获取 - User user = 2; // 从请求体获取 - string update_mask = 3; // 从查询参数获取 -} - -rpc UpdateUser(UpdateUserRequest) returns (User) { - option (google.api.http) = { - patch: "/v1/users/{user_id}" - body: "user" // 只有 user 字段从请求体解析 - }; -} -``` - -**实现细节:** -- 路径变量通过 `resolveBodyDesc` 解析字段描述符 -- 查询参数通过 `gatewayutils.PopulateQueryParameters` 填充到消息字段 -- 支持嵌套字段路径(如 `"user.profile"`) -- 自动处理类型转换(string → int/bool/double 等) - -#### 响应体映射(`response_body`) - -- `"*"` 或 `""`:返回整个响应消息 -- `"field_name"`:只返回指定字段的内容 - -**示例:** -```protobuf -message GetUserResponse { - User user = 1; - Metadata metadata = 2; -} - -rpc GetUser(GetUserRequest) returns (GetUserResponse) { - option (google.api.http) = { - get: "/v1/users/{user_id}" - response_body: "user" // 只返回 user 字段,metadata 被丢弃 - }; -} -``` - -**实现细节:** -- 通过字段描述符链(`getRspBodyDesc`)定位到目标字段 -- 只序列化目标字段的内容 -- 支持嵌套字段路径 - -### 4. 中间件支持 - -Gateway 支持两种类型的拦截器,可以用于日志记录、认证、授权、限流、监控等场景: - -#### Unary 拦截器 - -用于拦截 Unary(一元)RPC 调用,适用于同步的请求-响应模式: - -```go -mux.SetUnaryInterceptor(func( - ctx context.Context, - req interface{}, - info *grpc.UnaryServerInfo, - handler grpc.UnaryHandler, -) (interface{}, error) { - // 前置处理 - start := time.Now() - log.Info().Msgf("Calling method: %s", info.FullMethod) - - // 认证检查 - if err := authenticate(ctx); err != nil { - return nil, err - } - - // 调用实际处理函数 - resp, err := handler(ctx, req) - - // 后置处理 - duration := time.Since(start) - if err != nil { - log.Error().Err(err). - Dur("duration", duration). - Msg("Method call failed") - } else { - log.Info(). - Dur("duration", duration). - Msg("Method call succeeded") - } - - return resp, err -}) -``` - -**适用场景:** -- 请求日志记录 -- 认证和授权 -- 请求限流 -- 性能监控和指标收集 -- 错误处理和转换 -- 请求追踪(Trace) - -#### Stream 拦截器 - -用于拦截 Stream(流式)RPC 调用,可以拦截双向流、客户端流和服务器流: - -```go -mux.SetStreamInterceptor(func( - srv interface{}, - ss grpc.ServerStream, - info *grpc.StreamServerInfo, - handler grpc.StreamHandler, -) error { - // 包装 ServerStream 以拦截消息 - wrappedStream := &wrappedServerStream{ - ServerStream: ss, - } - - log.Info(). - Str("method", info.FullMethod). - Bool("client_stream", info.IsClientStream). - Bool("server_stream", info.IsServerStream). - Msg("Starting stream") - - err := handler(srv, wrappedStream) - - if err != nil { - log.Error().Err(err).Msg("Stream ended with error") - } else { - log.Info().Msg("Stream completed successfully") - } - - return err -}) -``` - -**适用场景:** -- 流式数据的监控和统计 -- 流式消息的日志记录 -- 流式连接的认证和授权 -- 流式数据的转换和过滤 - -### 5. 请求/响应拦截器 - -Gateway 支持针对特定消息类型的自定义编解码逻辑,可以在标准编解码前后进行自定义处理: - -#### 请求解码器(Request Decoder) - -请求解码器在标准 JSON → Protobuf 解码之前执行,可以: - -- 从 HTTP 头、Cookie 等位置读取数据并填充到消息字段 -- 修改请求消息内容 -- 验证和转换请求数据 -- 添加额外的上下文信息 - -```go -mux.SetRequestDecoder( - protoreflect.FullName("example.UserRequest"), - func(ctx *fiber.Ctx, msg proto.Message) error { - // 从 HTTP 头读取用户信息 - userID := ctx.Get("X-User-ID") - if userID != "" { - // 将用户 ID 填充到消息字段 - msg.ProtoReflect().Set( - msg.ProtoReflect().Descriptor().Fields().ByName("user_id"), - protoreflect.ValueOfString(userID), - ) - } - - // 从 Cookie 读取额外信息 - token := ctx.Cookies("auth_token") - // ... 处理 token - - return nil - }, -) -``` - -#### 响应编码器(Response Encoder) - -响应编码器在标准 Protobuf → JSON 编码之后执行,可以: - -- 修改响应格式 -- 添加额外的响应头 -- 过滤敏感信息 -- 包装响应数据 - -```go -mux.SetResponseEncoder( - protoreflect.FullName("example.UserResponse"), - func(ctx *fiber.Ctx, msg proto.Message) error { - // 添加自定义响应头 - ctx.Response().Header.Set("X-Custom-Header", "value") - - // 可以访问和修改消息内容 - // msg.ProtoReflect()... - - // 如果返回 nil,将使用标准编码 - // 如果返回错误,将中断响应处理 - return nil - }, -) -``` - -**执行时机:** -- 请求解码器:在 `streamHTTP.RecvMsg` 中,定位请求体字段之后,JSON 解码之前执行 -- 响应编码器:在 `streamHTTP.SendMsg` 中,定位响应字段之后,JSON 编码之前执行 - -**注意:** -- 解码器和编码器都是可选的,如果没有配置,将使用标准的 JSON 编解码 -- 解码器/编码器的参数类型必须与消息类型的 FullName 完全匹配 - -**使用场景:** -- 从 HTTP 头/Cookie 中提取认证信息 -- 数据脱敏和隐私保护 -- 响应格式定制 -- 添加额外的元数据 - -### 6. 错误处理 - -Gateway 提供了完整的 gRPC 错误码到 HTTP 状态码的自动映射,基于 [Google RPC Code](https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto) 规范。 - -#### 错误码映射表 - -完整的 gRPC 错误码到 HTTP 状态码映射: - -| gRPC Code | HTTP Status | 说明 | -|-----------|-------------|------| -| OK | 200 OK | 成功 | -| Canceled | 499 Client Closed Request | 客户端取消请求(非标准 HTTP 状态码) | -| Unknown | 500 Internal Server Error | 未知错误 | -| InvalidArgument | 400 Bad Request | 无效的参数 | -| DeadlineExceeded | 504 Gateway Timeout | 请求超时 | -| NotFound | 404 Not Found | 资源未找到 | -| AlreadyExists | 409 Conflict | 资源已存在 | -| PermissionDenied | 403 Forbidden | 权限不足 | -| ResourceExhausted | 429 Too Many Requests | 资源耗尽(通常用于限流) | -| FailedPrecondition | 400 Bad Request | 前置条件失败(不使用 412 Precondition Failed) | -| Aborted | 409 Conflict | 操作被中止 | -| OutOfRange | 400 Bad Request | 参数超出范围 | -| Unimplemented | 501 Not Implemented | 方法未实现 | -| Internal | 500 Internal Server Error | 内部服务器错误 | -| Unavailable | 503 Service Unavailable | 服务不可用 | -| DataLoss | 500 Internal Server Error | 数据丢失 | -| Unauthenticated | 401 Unauthorized | 未认证 | - -#### 错误处理机制 - -**自动映射:** -- Gateway 自动将 gRPC 错误转换为对应的 HTTP 状态码 -- 使用 `HTTPStatusFromCode` 函数进行转换 -- 未知的错误码会映射为 `500 Internal Server Error` - -**在拦截器中处理错误:** -```go -mux.SetUnaryInterceptor(func( - ctx context.Context, - req interface{}, - info *grpc.UnaryServerInfo, - handler grpc.UnaryHandler, -) (interface{}, error) { - resp, err := handler(ctx, req) - - // 检查错误并可以自定义处理 - if err != nil { - if st, ok := status.FromError(err); ok { - // 可以记录错误、添加额外信息等 - log.Error(). - Str("code", st.Code().String()). - Str("message", st.Message()). - Msg("gRPC error occurred") - - // 可以转换错误码或添加错误详情 - // return nil, status.Errorf(st.Code(), "custom error message: %v", st.Message()) - } - } - - return resp, err -}) -``` - -**自定义错误响应:** -- 在响应编码器中可以检查错误并自定义响应格式 -- 通过 Fiber Context 设置自定义的 HTTP 状态码和响应头 - -### 7. 元数据处理 - -Gateway 处理 HTTP 头和 gRPC 元数据之间的转换: - -- **HTTP → gRPC**:HTTP 请求头转换为 gRPC metadata -- **gRPC → HTTP**:gRPC header 和 trailer 转换为 HTTP 响应头 -- **二进制元数据**:支持 base64 编码的二进制元数据(`-bin` 后缀) - -保留的 HTTP 头(不会转换为元数据): -- `Content-Type` -- `User-Agent` -- `grpc-*` 相关的头 - -### 8. FieldMask 支持 - -Gateway 支持 Google FieldMask 协议,允许客户端指定要返回或更新的字段: - -**功能:** -- `FieldMaskFromRequestBody`: 从 JSON 请求体自动生成 FieldMask -- 支持嵌套字段路径(如 `user.profile.name`) -- 自动处理动态消息类型(`google.protobuf.Struct`、`google.protobuf.Value`) -- 支持 `google.protobuf.Any` 类型的字段过滤 - -**使用示例:** -```go -import ( - "github.com/pubgo/lava/v2/pkg/gateway" - "google.golang.org/protobuf/types/known/fieldmaskpb" -) - -// 从请求体中提取 FieldMask -func handleUpdateUser(ctx *fiber.Ctx) error { - req := &pb.UpdateUserRequest{} - - // 解析请求体 - if err := ctx.BodyParser(req); err != nil { - return err - } - - // 从请求体中提取 FieldMask(如果请求体包含字段,则生成对应的 FieldMask) - fm, err := gateway.FieldMaskFromRequestBody( - bytes.NewReader(ctx.Body()), - req, - ) - if err != nil { - return err - } - - // 使用 FieldMask 进行部分更新 - req.UpdateMask = fm - // ... 继续处理 - return nil -} -``` - -**使用场景:** -- **部分字段更新(PATCH)**:客户端只传递需要更新的字段,生成对应的 FieldMask -- **减少响应数据量**:客户端通过 FieldMask 指定只返回需要的字段 -- **GraphQL 风格的字段选择**:实现类似 GraphQL 的字段选择功能 - -## 使用示例 - -### 完整示例 - -以下是一个完整的示例,展示如何使用 Gateway: - -**1. Protobuf 定义(user.proto)** +### 1. 定义 Proto 文件 ```protobuf syntax = "proto3"; -package example; +package example.v1; import "google/api/annotations.proto"; @@ -732,461 +33,96 @@ service UserService { rpc CreateUser(CreateUserRequest) returns (User) { option (google.api.http) = { post: "/v1/users" - body: "user" - }; - } - - rpc UpdateUser(UpdateUserRequest) returns (User) { - option (google.api.http) = { - patch: "/v1/users/{user.id}" - body: "user" + body: "*" }; } } - -message GetUserRequest { - string user_id = 1; -} - -message CreateUserRequest { - User user = 1; -} - -message UpdateUserRequest { - User user = 1; -} - -message User { - string id = 1; - string name = 2; - string email = 3; -} -``` - -**2. 服务实现** - -```go -package main - -import ( - "context" - "github.com/pubgo/lava/v2/pkg/gateway" - pb "your/proto/package" -) - -type userServiceImpl struct { - pb.UnimplementedUserServiceServer - // 可以添加依赖,如数据库客户端等 -} - -func (s *userServiceImpl) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) { - // 实现逻辑 - return &pb.User{ - Id: req.UserId, - Name: "John Doe", - Email: "john@example.com", - }, nil -} - -func (s *userServiceImpl) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) { - // 实现逻辑 - return req.User, nil -} - -func (s *userServiceImpl) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.User, error) { - // 实现逻辑 - return req.User, nil -} ``` -**3. Gateway 配置和使用** +### 2. 实现并注册服务 ```go package main import ( - "time" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/lava/v2/pkg/gateway" pb "your/proto/package" ) func main() { - // 创建 Gateway,配置选项 - mux := gateway.NewMux( - gateway.MaxReceiveMessageSizeOption(4 * 1024 * 1024), // 4MB - gateway.ConnectionTimeoutOption(120 * time.Second), - ) - - // 可选:配置拦截器 - mux.SetUnaryInterceptor(func( - ctx context.Context, - req interface{}, - info *grpc.UnaryServerInfo, - handler grpc.UnaryHandler, - ) (interface{}, error) { - // 日志、认证等处理 - return handler(ctx, req) - }) - + // 创建 Gateway + mux := gateway.NewMux() + // 注册服务 - mux.RegisterService( - &pb.UserService_ServiceDesc, - &userServiceImpl{}, - ) - - // 集成到 Fiber 应用 + mux.RegisterService(&pb.UserService_ServiceDesc, &userServiceImpl{}) + + // 创建 Fiber 应用 app := fiber.New() - app.Use("/api", func(c *fiber.Ctx) error { - return mux.Handler(c) - }) - - // 启动服务器 - if err := app.Listen(":8080"); err != nil { - panic(err) - } + app.All("/v1/*", mux.Handler) + + app.Listen(":8080") } ``` -**4. 客户端调用示例** +### 3. 调用服务 ```bash # GET 请求 -curl http://localhost:8080/api/v1/users/123 +curl http://localhost:8080/v1/users/123 # POST 请求 -curl -X POST http://localhost:8080/api/v1/users \ +curl -X POST http://localhost:8080/v1/users \ -H "Content-Type: application/json" \ - -d '{"user": {"name": "Jane Doe", "email": "jane@example.com"}}' - -# PATCH 请求 -curl -X PATCH http://localhost:8080/api/v1/users/123 \ - -H "Content-Type: application/json" \ - -d '{"user": {"id": "123", "name": "Jane Smith"}}' -``` - -### 基本使用(简化版) - -### 与 gRPC 服务器集成 - -```go -// 在同一个进程中同时运行 gRPC 和 HTTP 服务 -func main() { - // 创建 Gateway(使用进程内通道) - mux := gateway.NewMux() - mux.RegisterService(&pb.Service_ServiceDesc, &serviceImpl{}) - - // HTTP 服务器 - httpApp := fiber.New() - httpApp.Use("/", mux.Handler) - go httpApp.Listen(":8080") - - // gRPC 服务器(可选,用于直接 gRPC 调用) - grpcServer := grpc.NewServer() - pb.RegisterServiceServer(grpcServer, &serviceImpl{}) - go grpcServer.Serve(lis) - - select {} -} -``` - -### 代理模式 - -```go -// 将 HTTP 请求代理到远程 gRPC 服务 -func main() { - mux := gateway.NewMux() - - // 创建 GrpcRouter 实现(可选,如果需要配置中间件) - type userServiceProxy struct{} - - func (p *userServiceProxy) ServiceDesc() *grpc.ServiceDesc { - return &pb.UserService_ServiceDesc - } - - func (p *userServiceProxy) Middlewares() []lava.Middleware { - return []lava.Middleware{ - // 可以添加代理服务的中间件 - } - } - - // 连接到远程 gRPC 服务(可以配置负载均衡、服务发现等) - conn, err := grpc.Dial( - "remote-service:50051", - grpc.WithInsecure(), - grpc.WithBalancerName("round_robin"), // 负载均衡 - // 更多选项... - ) - if err != nil { - log.Fatal().Err(err).Msg("failed to connect to gRPC service") - } - defer conn.Close() - - // 注册代理服务 - proxy := &userServiceProxy{} - mux.RegisterProxy( - &pb.UserService_ServiceDesc, - proxy, - conn, - ) - - app := fiber.New() - app.Use("/api", mux.Handler) - app.Listen(":8080") -} -``` - -## 配置选项 - -### MuxOption - -```go -// 最大接收消息大小(默认 4MB) -MaxReceiveMessageSizeOption(size int) - -// 最大发送消息大小(默认 math.MaxInt32) -MaxSendMessageSizeOption(size int) - -// 连接超时(默认 120s) -ConnectionTimeoutOption(d time.Duration) - -// 自定义类型解析器 -TypesOption(resolver protoregistry.MessageTypeResolver) - -// 自定义文件注册表 -FilesOption(files *protoregistry.Files) - -// 注册自定义编解码器 -CodecOption(contentType string, codec Codec) - -// 注册自定义压缩器 -CompressorOption(contentEncoding string, compressor Compressor) -``` - -## 路径匹配规则 - -### 路径变量 - -- `{field}`:匹配单个路径段,例如 `/users/{id}` 匹配 `/users/123` -- `{field=pattern}`:匹配指定的路径模式,例如 `/users/{id=projects/*/users/*}` 匹配 `/users/projects/p1/users/u1` -- `{field.subfield}`:嵌套字段路径,用于映射到 Protobuf 消息的嵌套字段 - -### 通配符 - -- `*`:匹配单个路径段(非贪婪),例如 `/files/*` 匹配 `/files/image.jpg` 但不匹配 `/files/images/2023/photo.jpg` -- `**`:匹配多个路径段(贪婪匹配),例如 `/files/**` 匹配 `/files/images/2023/photo.jpg` 以及任意深度的路径 -- 通配符与路径变量的组合:`{field=**}` 表示变量匹配多个路径段 - -### 动词 - -- `:verb`:路径末尾的动词,用于区分相同路径的不同操作 -- 例如 `/users/{id}:get` 和 `/users/{id}:delete` 可以区分不同的操作 -- 动词是可选的,如果没有动词,路由通过 HTTP 方法区分 - -### 匹配优先级 - -路由匹配按照以下优先级: - -1. **精确匹配**:完全匹配的路径段优先 -2. **路径变量**:`{field}` 形式的变量匹配 -3. **单段通配符**:`*` 匹配单个路径段 -4. **多段通配符**:`**` 贪婪匹配剩余所有路径段 - -### 示例 - -| 路径模板 | 匹配示例 | 变量 | 说明 | -|---------|---------|------|------| -| `/v1/users/{user_id}` | `/v1/users/123` | `user_id=123` | 简单路径变量 | -| `/v1/users/{user.id=projects/*/users/*}` | `/v1/users/projects/p1/users/u1` | `user.id=projects/p1/users/u1` | 带模式的路径变量 | -| `/v1/files/*` | `/v1/files/image.jpg` | - | 单段通配符 | -| `/v1/files/{name=**}` | `/v1/files/images/2023/photo.jpg` | `name=images/2023/photo.jpg` | 多段通配符变量 | -| `/v1/users/{user_id}/profile:get` | `/v1/users/123/profile:get` | `user_id=123`, verb=`get` | 带动词的路由 | - -### 性能特性 - -路由匹配具有以下性能特性: - -- **时间复杂度**:O(d),其中 d 是路径深度(路径段数),与路由数量无关 -- **空间复杂度**:O(n),其中 n 是路由数量,使用前缀树结构存储 -- **匹配速度**:每个路径段只需要一次 map 查找,非常高效 -- **内存优化**:使用指针共享公共路径前缀,减少内存占用 - -## 实现细节 - -### 路径解析 - -Gateway 使用 [participle](https://github.com/alecthomas/participle) 解析 HTTP Rule 路径模板: - -**解析流程:** -1. **词法分析**:将路径字符串分解为 tokens(标识符、标点符号等) -2. **语法解析**:根据 HTTP Rule 语法规则构建路径 AST -3. **路径规范化**:提取路径变量、通配符、动词等信息 -4. **路由树构建**:将解析后的路径信息添加到路由树中 - -**支持的路径元素:** -- 字面量路径段(如 `/v1/users`) -- 路径变量(`{field}` 或 `{field.subfield}`) -- 带模式的路径变量(`{field=pattern}`,如 `{user.id=projects/*/users/*}`) -- 单段通配符(`*`) -- 多段通配符(`**`,贪婪匹配) -- 动词(`:verb`,如 `/users/{id}:get`) - -### 进程内调用 - -Gateway 使用 [inprocgrpc](https://github.com/fullstorydev/grpchan) 实现进程内的 gRPC 调用: - -**优势:** -- **零网络开销**:进程内直接调用,无需序列化/反序列化网络数据 -- **类型安全**:编译时类型检查,运行时无类型转换开销 -- **调试友好**:可以直接进行调试和堆栈跟踪 -- **性能最优**:避免了网络延迟和协议开销 - -**实现方式:** -```go -localClient := new(inprocgrpc.Channel) -localClient.RegisterService(sd, ss) // 注册服务到进程内通道 + -d '{"name": "John", "email": "john@example.com"}' ``` -**使用场景:** -- 同一进程内的服务调用 -- API Gateway 与后端服务在同一进程 -- 减少服务间调用的延迟和开销 - -### 查询参数处理 - -Gateway 支持将 HTTP 查询参数映射到 Protobuf 消息字段: - -**处理流程:** -1. 从 HTTP 请求 URL 中提取查询参数(`?key=value&key2=value2`) -2. 合并路径变量(从路径中提取的变量) -3. 根据字段名和 JSON 名称匹配 Protobuf 字段 -4. 执行类型转换(string → int32/int64/bool/double/float 等) -5. 处理数组参数(重复的查询参数键) - -**特性:** -- **嵌套字段支持**:使用点号分隔的字段路径(如 `user.profile.name`) -- **类型自动转换**:自动将字符串转换为目标类型 -- **数组参数**:支持 `?ids=1&ids=2&ids=3` 这样的数组参数 -- **默认值处理**:支持 Protobuf 字段的默认值 - -**实现细节:** -- 使用 `gatewayutils.PopulateQueryParameters` 函数处理参数填充 -- 通过反射和 Protobuf 描述符系统访问和设置字段值 -- 支持基本类型、枚举、消息类型的转换 - -### 元数据转换 +## 文档 -Gateway 实现了 HTTP 头和 gRPC metadata 之间的双向转换: +| 文档 | 说明 | +| -------------------------------- | -------------------------------------- | +| [使用指南](docs/usage.md) | 服务注册、路由配置、中间件、错误处理等 | +| [gRPC Web](docs/grpcweb.md) | 浏览器端 gRPC Web 集成 | +| [架构设计](docs/architecture.md) | 核心组件、数据结构、处理流程 | +| [实现细节](docs/internals.md) | 路径解析、元数据转换、流式处理等 | -**HTTP → gRPC:** -- 过滤保留的 HTTP 头(`Content-Type`、`User-Agent`、`grpc-*` 等) -- 将 HTTP 头转换为小写的 gRPC metadata key -- 处理二进制元数据(`-bin` 后缀的头,使用 base64 解码) +## 支持的协议 -**gRPC → HTTP:** -- 将 gRPC header 和 trailer 转换为 HTTP 响应头 -- 二进制元数据使用 base64 编码,并添加 `-bin` 后缀 -- 保留的 gRPC 元数据不会转换为 HTTP 头 +| 协议 | Content-Type | 说明 | +| ---------------- | --------------------------------- | ---------------------------- | +| HTTP/JSON | `application/json` | RESTful API | +| HTTP/JSON (别名) | `application/grpc-web-json` | 前端命名兼容(按 JSON 处理) | +| gRPC Web | `application/grpc-web+proto` | 浏览器 gRPC (二进制) | +| gRPC Web Text | `application/grpc-web-text+proto` | 浏览器 gRPC (Base64) | -**特殊处理:** -- `authorization` 头:直接传递,不加前缀 -- `X-Forwarded-*` 头:自动处理代理场景 -- 二进制数据:使用 `base64.RawStdEncoding` 进行编解码 +## 路径匹配 -### 错误处理 +| 模式 | 示例 | 说明 | +| --------- | ----------------- | ---------- | +| `{field}` | `/users/{id}` | 路径变量 | +| `*` | `/files/*` | 单段通配符 | +| `**` | `/files/**` | 多段通配符 | +| `:verb` | `/users/{id}:get` | 动词后缀 | -Gateway 提供了完整的 gRPC 错误码到 HTTP 状态码的映射: +## 错误码映射 -**映射规则:** -- 基于 [Google RPC Code](https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto) 规范 -- 提供 `HTTPStatusFromCode` 函数进行转换 -- 支持所有标准的 gRPC 错误码 +| gRPC Code | HTTP Status | +| ---------------- | ----------- | +| OK | 200 | +| InvalidArgument | 400 | +| Unauthenticated | 401 | +| PermissionDenied | 403 | +| NotFound | 404 | +| Internal | 500 | -**特殊处理:** -- `Canceled` → `499 Client Closed Request`(非标准但常用) -- `FailedPrecondition` → `400 Bad Request`(不使用 412 Precondition Failed) -- `Unknown` → `500 Internal Server Error`(兜底处理) +完整映射表见 [实现细节](docs/internals.md#错误码映射)。 -### 流式处理 +## 示例 -Gateway 实现了 `grpc.ServerStream` 接口,支持不同类型的流式 RPC: - -**流类型实现:** - -1. **HTTP Stream(streamHTTP)** - - 基于 Fiber Context 的同步请求/响应 - - 实现 `RecvMsg` 和 `SendMsg` 方法 - - 处理请求体解析和响应体编码 - - 管理 HTTP 头和 trailer - -2. **WebSocket Stream(streamWebSocket)** - - 双向通信流 - - 支持客户端和服务器双向消息传输 - - 适用于实时通信场景 - -3. **In-Process Stream(streamInProcess)** - - 进程内流,用于本地服务调用 - - 直接传递消息,无需序列化 - - 性能最优的流式处理方式 - -4. **Proxy Stream(streamProxy)** - - 代理流,转发到远程 gRPC 服务 - - 支持负载均衡和服务发现 - - 适用于微服务架构 - -**流式处理特点:** -- 统一的 `grpc.ServerStream` 接口 -- 支持 header 和 trailer 的设置 -- 消息边界由编解码器决定(使用 `StreamCodec.ReadNext/WriteNext`) - -## 依赖关系 - -Gateway 模块依赖以下核心库: - -- `google.golang.org/grpc`:gRPC 核心库 -- `google.golang.org/protobuf`:Protobuf 运行时 -- `github.com/gofiber/fiber/v2`:HTTP 框架 -- `github.com/fullstorydev/grpchan/inprocgrpc`:进程内 gRPC 通道 -- `github.com/alecthomas/participle/v2`:路径模板解析器 - -## 设计总结 - -### 核心优势 - -1. **声明式配置**:通过 Protobuf 注解定义路由,代码即文档,减少维护成本 -2. **类型安全**:基于 Protobuf 的类型系统,编译时和运行时都有类型保障 -3. **高性能**:进程内调用零网络开销,编解码使用高效的 Protobuf 和 protojson -4. **灵活扩展**:支持自定义编解码器、拦截器、压缩器等,满足各种定制需求 -5. **标准兼容**:遵循 Google API HTTP Annotation 规范,与现有工具链兼容 - -### 适用场景 - -- **API Gateway**:将内部 gRPC 服务暴露为 RESTful API -- **微服务架构**:统一 HTTP/JSON 接口,后端使用 gRPC 通信 -- **混合部署**:同时支持 HTTP 和 gRPC 客户端访问 -- **前端集成**:为前端应用提供标准的 REST API -- **API 版本管理**:通过路径前缀管理不同版本的 API - -### 最佳实践 - -1. **使用 HTTP Rule 注解**:在 Protobuf 定义中使用 `google.api.http` 注解,而不是手动注册路由 -2. **合理使用 body 映射**:对于包含大量字段的消息,使用 `body: "field_name"` 只映射需要的字段 -3. **利用进程内调用**:对于同一进程的服务,使用本地服务注册而不是代理模式 -4. **使用拦截器**:通过 Unary/Stream 拦截器实现通用的横切关注点(日志、认证、监控等) -5. **错误处理**:利用 Gateway 的错误映射,保持 gRPC 错误码的一致性 -6. **元数据管理**:使用 HTTP 头传递认证信息和元数据,Gateway 会自动转换为 gRPC metadata - -### 性能考虑 - -- **进程内调用**:相比网络调用,进程内调用延迟更低,吞吐量更高 -- **编解码开销**:JSON 编解码相比二进制 Protobuf 有额外开销,但提供了更好的可读性 -- **路由匹配**:路由树使用高效的树形结构,匹配时间复杂度为 O(path_depth) -- **并发处理**:Gateway 本身是无状态的,可以安全地处理并发请求 +- [gRPC Web 示例](../../internal/examples/grpcweb/) - 完整的 gRPC Web 前后端示例 ## 参考 -- [Google API HTTP Annotation](https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api#google.api.DocumentationRule.FIELDS.string.google.api.DocumentationRule.selector) +- [Google API HTTP Annotation](https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api) - [gRPC Gateway](https://github.com/grpc-ecosystem/grpc-gateway) -- [AIP-123: Resource-oriented design](https://google.aip.dev/123) -- [gRPC HTTP/2 Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md) - +- [gRPC Web Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) diff --git a/pkg/gateway/_doc.go b/pkg/gateway/_doc.go index 5c610c0d5..c79518174 100644 --- a/pkg/gateway/_doc.go +++ b/pkg/gateway/_doc.go @@ -1,7 +1,39 @@ +// Package gateway 提供 gRPC Gateway 功能,实现 HTTP/JSON 到 gRPC 的协议转换。 +// +// Gateway 基于 Google API HTTP Annotation 规范,支持: +// - HTTP Rule 解析:支持 google.api.http 注解,自动解析 RESTful 路径模板 +// - 协议转换:HTTP/JSON ↔ gRPC/Protobuf 双向自动转换 +// - 路由匹配:支持精确匹配、* 单段通配符、** 多段通配符、动词 (:verb) +// - 服务注册:支持本地服务 (RegisterService) 和代理服务 (RegisterProxy) +// - 中间件:Unary 和 Stream 拦截器 +// - 多种流类型:HTTP、WebSocket、进程内、代理 +// - gRPC Web 支持:支持 application/grpc-web 和 application/grpc-web-text 内容类型 +// +// 基本用法: +// +// mux := gateway.NewMux() +// mux.RegisterService(&pb.MyService_ServiceDesc, &myServiceImpl{}) +// app.Use("/api", mux.Handler) +// +// gRPC Web 用法: +// +// mux := gateway.NewMux() +// mux.RegisterService(&pb.MyService_ServiceDesc, &myServiceImpl{}) +// http.ListenAndServe(":8080", mux) +// +// 浏览器可以通过 gRPC Web 协议调用 gRPC 服务。 +// +// 参考资料: +// - https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api +// - https://google.aip.dev/123 +// - https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md +// +// 参考资料: +// - https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api +// - https://google.aip.dev/123 package gateway -// https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api#google.api.DocumentationRule.FIELDS.string.google.api.DocumentationRule.selector -// https://google.aip.dev/123 +// 相关项目参考: // https://github.com/dgrr/http2 // https://github.com/r3labs/sse // https://github.com/googollee/go-socket.io diff --git a/pkg/gateway/aaa.go b/pkg/gateway/aaa.go index caabb7414..937856668 100644 --- a/pkg/gateway/aaa.go +++ b/pkg/gateway/aaa.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "google.golang.org/grpc" "google.golang.org/grpc/encoding" "google.golang.org/protobuf/proto" @@ -22,12 +22,12 @@ type ( SetUnaryInterceptor(interceptor grpc.UnaryServerInterceptor) SetStreamInterceptor(interceptor grpc.StreamServerInterceptor) - SetRequestDecoder(protoreflect.FullName, func(ctx *fiber.Ctx, msg proto.Message) error) - SetResponseEncoder(protoreflect.FullName, func(ctx *fiber.Ctx, msg proto.Message) error) + SetRequestDecoder(protoreflect.FullName, func(ctx fiber.Ctx, msg proto.Message) error) + SetResponseEncoder(protoreflect.FullName, func(ctx fiber.Ctx, msg proto.Message) error) RegisterService(sd *grpc.ServiceDesc, ss any) GetOperation(operation string) *GrpcMethod - Handler(*fiber.Ctx) error + Handler(fiber.Ctx) error ServeHTTP(http.ResponseWriter, *http.Request) GetRouteMethods() []RouteOperation } diff --git a/pkg/gateway/codec.go b/pkg/gateway/codec.go index e921cd09a..1aa32f84b 100644 --- a/pkg/gateway/codec.go +++ b/pkg/gateway/codec.go @@ -217,16 +217,18 @@ func (CodecJSON) Name() string { return "json" } type codecHTTPBody struct{} +var errHTTPBodyNotImplemented = fmt.Errorf("codecHTTPBody: method not implemented, use raw body directly") + func (codecHTTPBody) Marshal(v any) ([]byte, error) { - panic("not implemented") + return nil, errHTTPBodyNotImplemented } func (codecHTTPBody) MarshalAppend(b []byte, v any) ([]byte, error) { - panic("not implemented") + return nil, errHTTPBodyNotImplemented } func (codecHTTPBody) Unmarshal(data []byte, v any) error { - panic("not implemented") + return errHTTPBodyNotImplemented } func (codecHTTPBody) Name() string { return "body" } diff --git a/pkg/gateway/context.go b/pkg/gateway/context.go index c5b9468b9..8fbfd6b19 100644 --- a/pkg/gateway/context.go +++ b/pkg/gateway/context.go @@ -88,79 +88,6 @@ func isValidGRPCMetadataTextValue(textValue string) bool { return true } -//func annotateContext(ctx context.Context, mux *ServeMux, req *http.Request, rpcMethodName string, options ...AnnotateContextOption) (context.Context, metadata.MD, error) { -// ctx = withRPCMethod(ctx, rpcMethodName) -// for _, o := range options { -// ctx = o(ctx) -// } -// timeout := DefaultContextTimeout -// if tm := req.Header.Get(metadataGrpcTimeout); tm != "" { -// var err error -// timeout, err = timeoutDecode(tm) -// if err != nil { -// return nil, nil, status.Errorf(codes.InvalidArgument, "invalid grpc-timeout: %s", tm) -// } -// } -// var pairs []string -// for key, vals := range req.Header { -// key = textproto.CanonicalMIMEHeaderKey(key) -// for _, val := range vals { -// // For backwards-compatibility, pass through 'authorization' header with no prefix. -// if key == "Authorization" { -// pairs = append(pairs, "authorization", val) -// } -// if h, ok := mux.incomingHeaderMatcher(key); ok { -// if !isValidGRPCMetadataKey(h) { -// grpclog.Errorf("HTTP header name %q is not valid as gRPC metadata key; skipping", h) -// continue -// } -// // Handles "-bin" metadata in grpc, since grpc will do another base64 -// // encode before sending to server, we need to decode it first. -// if strings.HasSuffix(key, metadataHeaderBinarySuffix) { -// b, err := decodeBinHeader(val) -// if err != nil { -// return nil, nil, status.Errorf(codes.InvalidArgument, "invalid binary header %s: %s", key, err) -// } -// -// val = string(b) -// } else if !isValidGRPCMetadataTextValue(val) { -// grpclog.Errorf("Value of HTTP header %q contains non-ASCII value (not valid as gRPC metadata): skipping", h) -// continue -// } -// pairs = append(pairs, h, val) -// } -// } -// } -// if host := req.Header.Get(xForwardedHost); host != "" { -// pairs = append(pairs, strings.ToLower(xForwardedHost), host) -// } else if req.Host != "" { -// pairs = append(pairs, strings.ToLower(xForwardedHost), req.Host) -// } -// -// if addr := req.RemoteAddr; addr != "" { -// if remoteIP, _, err := net.SplitHostPort(addr); err == nil { -// if fwd := req.Header.Get(xForwardedFor); fwd == "" { -// pairs = append(pairs, strings.ToLower(xForwardedFor), remoteIP) -// } else { -// pairs = append(pairs, strings.ToLower(xForwardedFor), fmt.Sprintf("%s, %s", fwd, remoteIP)) -// } -// } -// } -// -// if timeout != 0 { -// //nolint:govet // The context outlives this function -// ctx, _ = context.WithTimeout(ctx, timeout) -// } -// if len(pairs) == 0 { -// return ctx, nil, nil -// } -// md := metadata.Pairs(pairs...) -// for _, mda := range mux.metadataAnnotators { -// md = metadata.Join(md, mda(ctx, req)) -// } -// return ctx, md, nil -//} - // ServerMetadata consists of metadata sent from gRPC server. type ServerMetadata struct { HeaderMD metadata.MD diff --git a/pkg/gateway/docs/architecture.md b/pkg/gateway/docs/architecture.md new file mode 100644 index 000000000..5fb5fef17 --- /dev/null +++ b/pkg/gateway/docs/architecture.md @@ -0,0 +1,409 @@ +# Gateway 架构设计 + +本文档详细介绍 Gateway 模块的架构设计、核心组件和数据结构。 + +## 设计理念 + +Gateway 模块基于 **Google API HTTP Annotation** 规范,实现了从 HTTP/REST 到 gRPC 的透明转换。其设计遵循以下原则: + +1. **声明式路由**:通过 Protobuf 注解定义 HTTP 路由,无需手动编写路由代码 +2. **协议透明**:客户端使用标准的 HTTP/JSON,后端使用 gRPC,Gateway 自动处理转换 +3. **类型安全**:基于 Protobuf 的类型系统,保证请求/响应的类型安全 +4. **可扩展性**:支持自定义编解码器、拦截器、压缩器等扩展点 + +## 模块结构 + +``` +pkg/gateway/ +├── mux.go # 核心路由器 Mux,实现 Gateway 接口 +├── routertree/ # 路由树实现,负责路径匹配 +│ ├── router.go # 路由树核心逻辑 +│ ├── parser.go # HTTP Rule 路径模板解析器 +│ └── lex.go # 词法分析器 +├── codec.go # 编解码器接口和实现(JSON、Protobuf) +├── stream.go # Stream 接口定义 +├── stream.http.go # HTTP 流实现 +├── stream.grpcweb.go # gRPC Web 流实现 +├── stream.websocket.go # WebSocket 流实现 +├── stream.inprocess.go # 进程内流实现 +├── stream.proxy.go # 代理流实现 +├── context.go # 上下文和元数据管理 +├── util.go # 工具函数(HTTP Rule 解析、元数据转换等) +├── fieldmask.go # FieldMask 支持 +├── wrapper.go # 服务和方法包装器 +├── grpccodes.go # gRPC 错误码到 HTTP 状态码映射 +├── gatewayutils/ # Gateway 工具函数 +│ ├── query_params.go # 查询参数处理 +│ └── trie.go # Trie 树实现 +└── internal/ # 内部实现(压缩器等) +``` + +## 核心组件 + +```mermaid +C4Container +title Gateway 核心组件(C4 Container) + +Person(client, "HTTP/gRPC-Web Client", "调用 Gateway API") + +System_Boundary(gateway, "pkg/gateway") { + Container(mux, "Gateway (Mux)", "Go", "核心路由器,管理请求生命周期与调度") + Container(routerTree, "RouterTree", "Go", "路径解析、变量提取、路由匹配") + Container(codec, "Codec", "Go", "JSON/Protobuf 编解码与扩展") + Container(stream, "Stream", "Go", "HTTP、gRPC-Web、Proxy 等流处理") + Container(ctxmeta, "Context", "Go", "HTTP↔gRPC metadata 转换") + Container(fieldmask, "FieldMask", "Go", "字段掩码与局部更新支持") + Container(wrapper, "Wrapper", "Go", "服务/方法包装,本地与代理调用抽象") +} + +Rel(client, mux, "HTTP / gRPC-Web") +Rel(mux, routerTree, "Match(method, path)") +Rel(mux, ctxmeta, "构建/透传 metadata") +Rel(mux, stream, "RecvMsg / SendMsg / Stream invoke") +Rel(stream, codec, "Marshal / Unmarshal") +Rel(stream, fieldmask, "请求字段映射") +Rel(mux, wrapper, "方法查找与调用分发") +``` + +### 核心组件分层(美化版) + +```mermaid +flowchart TB + client([HTTP / gRPC-Web Client]) + + subgraph L0[接入层] + mux[Gateway Mux\n请求编排与分发] + end + + subgraph L1[核心处理层] + router[RouterTree\n路径匹配/变量提取] + stream[Stream\nRecvMsg/SendMsg/流式处理] + codec[Codec\nJSON/Protobuf 编解码] + end + + subgraph L2[支撑能力层] + ctx[Context\nMetadata 转换] + mask[FieldMask\n字段掩码映射] + wrapper[Wrapper\n本地/代理调用包装] + end + + client --> mux + mux --> router + mux --> stream + mux --> ctx + mux --> wrapper + stream --> codec + stream --> mask + + classDef client fill:#E8F4FF,stroke:#4A90E2,stroke-width:1.2px,color:#0B3D91; + classDef entry fill:#EAFBF1,stroke:#2E8B57,stroke-width:1.2px,color:#165B33; + classDef core fill:#FFF7E8,stroke:#C87B00,stroke-width:1.2px,color:#7A4A00; + classDef support fill:#F4EEFF,stroke:#7A5AF8,stroke-width:1.2px,color:#4C33B6; + + class client client; + class mux entry; + class router,stream,codec core; + class ctx,mask,wrapper support; +``` + +## 请求处理流程 + +``` +HTTP Request (JSON / gRPC Web) + │ + ├─> [1. Handler] 接收 Fiber Context + │ + ├─> [2. 协议检测] + │ ├─ HTTP/JSON → 普通流程 + │ └─ gRPC Web → gRPC Web 流程 + │ + ├─> [3. RouterTree.Match] + │ ├─ 解析 HTTP 方法和路径 + │ ├─ 匹配路由规则(支持通配符、变量、动词) + │ └─ 提取路径变量和查询参数 + │ + ├─> [4. Method Lookup] + │ └─ 根据 gRPC 方法名查找对应的 methodWrapper + │ + ├─> [5. Metadata Conversion] + │ └─ 将 HTTP 头转换为 gRPC metadata + │ + ├─> [6. Stream.RecvMsg] + │ ├─ 根据 body 规则解析请求体 + │ ├─ 合并路径变量和查询参数到消息字段 + │ └─ 执行请求拦截器 + │ + ├─> [7. Mux.Invoke] + │ ├─ 判断是本地服务还是代理服务 + │ ├─ 本地服务:使用 inprocgrpc.Channel 调用 + │ └─ 代理服务:转发到远程 gRPC 客户端 + │ + ├─> [8. gRPC Service Execution] + │ ├─ 执行 Unary/Stream 拦截器 + │ └─ 调用实际的 gRPC 服务方法 + │ + ├─> [9. Stream.SendMsg] + │ ├─ 根据 response_body 规则定位响应字段 + │ ├─ 执行响应编码器 + │ └─ 编码并写入响应 + │ + └─> [10. Response] 返回响应 +``` + +### Handler 详细流程图 + +```mermaid +flowchart TD + A["Handler(ctx)"] --> B{"isWebRequestFromContentType?"} + B -- Yes --> C["gRPC-Web 预处理"] + B -- No --> D["普通分支"] + + C --> C1{"Upgrade=websocket?"} + C1 -- Yes --> Cx["返回 500: unimplemented"] + C1 -- No --> C2["改写 Content-Type 为 application/grpc+enc"] + C2 --> C3{"typ == grpc-web-text?"} + C3 -- Yes --> C4["Base64 解码 body/stream"] + C3 -- No --> C5["跳过"] + C4 --> C6["创建 fiberWebWriter"] + C5 --> C6 + C6 --> E["routerTree.Match(method,path)"] + + D --> E + + E --> F{"匹配成功?"} + F -- No --> Fx["返回 match operation failed"] + F -- Yes --> G["提取 path vars + 合并 query"] + + G --> H["handlers operation 查找 methodWrapper"] + H --> I{"methodWrapper 存在?"} + I -- No --> Ix["返回 method operation not found"] + I -- Yes --> J["构建 metadata.MD"] + J --> K["构建 streamHTTP"] + + K --> L["stream.RecvMsg(in)"] + L --> M{"反序列化成功?"} + M -- No --> Mx["返回 unmarshal request failed"] + M -- Yes --> N["invokeWithStream"] + + N --> O{"grpcStreamDesc != nil?"} + O -- No --> P["Unary: Invoke + SendMsg"] + O -- Yes --> Q["Server Stream: NewStream/Recv loop/SendHeader/SendMsg/Trailer"] + + P --> R["写响应头 version/operation"] + Q --> R + R --> S{"gRPC-Web 分支?"} + S -- Yes --> T["flushWithTrailer"] + S -- No --> U["结束"] + T --> U + + classDef entry fill:#E8F4FF,stroke:#4A90E2,stroke-width:1.2px,color:#0B3D91; + classDef decision fill:#F4EEFF,stroke:#7A5AF8,stroke-width:1.2px,color:#4C33B6; + classDef process fill:#FFF7E8,stroke:#C87B00,stroke-width:1.2px,color:#7A4A00; + classDef success fill:#EAFBF1,stroke:#2E8B57,stroke-width:1.2px,color:#165B33; + classDef error fill:#FFECEC,stroke:#D14343,stroke-width:1.2px,color:#7D1F1F; + + class A entry; + class B,C1,C3,F,I,M,O,S decision; + class C,D,C2,C4,C5,C6,E,G,H,J,K,L,N,P,Q,R,T process; + class U success; + class Cx,Fx,Ix,Mx error; +``` + +### RouterTree.Match 路由匹配流程图 + +```mermaid +flowchart TD + A["Match(method,url)"] --> B["parseURL to pathNodes, verb"] + B --> C["verbKey = METHOD:verb"] + C --> D{"根路径?"} + D -- Yes --> E["查 root 节点 verbMap"] + D -- No --> F["递归匹配 pathNodes"] + + F --> G{"精确节点命中?"} + G -- Yes --> H{"最后一段?"} + H -- Yes --> I["查 verbMap 命中返回"] + H -- No --> F + + G -- No --> J{"star 节点命中?"} + J -- Yes --> K{"最后一段?"} + K -- Yes --> I + K -- No --> F + + J -- No --> L{"double star 节点命中?"} + L -- Yes --> I + L -- No --> M["ErrPathNodeNotFound or ErrOperationNotFound"] + + classDef entry fill:#E8F4FF,stroke:#4A90E2,stroke-width:1.2px,color:#0B3D91; + classDef decision fill:#F4EEFF,stroke:#7A5AF8,stroke-width:1.2px,color:#4C33B6; + classDef process fill:#FFF7E8,stroke:#C87B00,stroke-width:1.2px,color:#7A4A00; + classDef success fill:#EAFBF1,stroke:#2E8B57,stroke-width:1.2px,color:#165B33; + classDef error fill:#FFECEC,stroke:#D14343,stroke-width:1.2px,color:#7D1F1F; + + class A entry; + class D,G,H,J,K,L decision; + class B,C,E,F process; + class I success; + class M error; +``` + +### gRPC-Web-JSON 专项流程图 + +`application/grpc-web-json` 在当前实现中会走 **gRPC-Web 入口**,但在编解码阶段按 **JSON 传输** 处理(不走 gRPC frame)。 + +```mermaid +flowchart TD + A["请求 Content-Type = application/grpc-web-json"] --> B["Handler 命中 gRPC-Web 分支"] + B --> C["改写请求头为 application/grpc+json"] + C --> D["创建 fiberWebWriter"] + D --> E["routerTree.Match"] + E --> F["构建 streamHTTP 并 RecvMsg"] + F --> G["isGRPCContentType(application/grpc-web-json) = false"] + G --> H["按 JSON 反序列化请求体"] + H --> I["invokeWithStream 调用后端 gRPC"] + I --> J["SendMsg 时按 JSON 序列化响应"] + J --> K["fiberWebWriter 写出 grpc-web 响应并 flush trailer"] + + classDef entry fill:#E8F4FF,stroke:#4A90E2,stroke-width:1.2px,color:#0B3D91; + classDef decision fill:#F4EEFF,stroke:#7A5AF8,stroke-width:1.2px,color:#4C33B6; + classDef process fill:#FFF7E8,stroke:#C87B00,stroke-width:1.2px,color:#7A4A00; + + class A entry; + class G decision; + class B,C,D,E,F,H,I,J,K process; +``` + +> 说明:`stream.http.go` 中 `isGRPCContentType` 对 `application/grpc-web-json` 做了显式兼容,返回 `false`;相关行为由 `stream_http_test.go` 的 `TestIsGRPCContentType_GrpcWebJSONAlias` 覆盖。 + +### grpc-web+proto vs grpc-web-json 差异对比 + +| 维度 | grpc-web+proto | grpc-web-json | +| ------------------- | ------------------------------------------------ | ---------------------------------- | +| 入口判定 | `isWebRequestFromContentType` 命中 | `isWebRequestFromContentType` 命中 | +| 请求头改写 | `application/grpc+proto` | `application/grpc+json` | +| `isGRPCContentType` | `true` | `false`(别名按 JSON 处理) | +| 请求解码 | gRPC frame + protobuf | JSON 反序列化 | +| 响应编码 | protobuf + gRPC frame | JSON 序列化 | +| 输出封装 | 由 `fiberWebWriter` 负责 grpc-web 响应与 trailer | 同左 | + +```mermaid +flowchart LR + subgraph P["grpc-web+proto"] + P1["Content-Type: application/grpc-web+proto"] --> P2["Handler gRPC-Web 分支"] + P2 --> P3["改写为 application/grpc+proto"] + P3 --> P4["isGRPCContentType = true"] + P4 --> P5["RecvMsg: 解析 gRPC frame + protobuf"] + P5 --> P6["SendMsg: protobuf + gRPC frame"] + P6 --> P7["fiberWebWriter flush trailer"] + end + + subgraph J["grpc-web-json"] + J1["Content-Type: application/grpc-web-json"] --> J2["Handler gRPC-Web 分支"] + J2 --> J3["改写为 application/grpc+json"] + J3 --> J4["isGRPCContentType = false"] + J4 --> J5["RecvMsg: JSON 反序列化"] + J5 --> J6["SendMsg: JSON 序列化"] + J6 --> J7["fiberWebWriter flush trailer"] + end + + classDef proto fill:#EAFBF1,stroke:#2E8B57,stroke-width:1.2px,color:#165B33; + classDef json fill:#E8F4FF,stroke:#4A90E2,stroke-width:1.2px,color:#0B3D91; + + class P1,P2,P3,P4,P5,P6,P7 proto; + class J1,J2,J3,J4,J5,J6,J7 json; +``` + +## 关键数据结构 + +### 1. Mux(核心路由器) + +`Mux` 是 Gateway 的核心结构,实现了 `Gateway` 接口和 `grpc.ClientConnInterface`: + +**主要字段:** +- `localClient`: 进程内 gRPC 通道(`inprocgrpc.Channel`),用于调用本地服务 +- `routerTree`: 路由树,存储和匹配 HTTP 路由规则 +- `opts`: 配置选项,包括编解码器、拦截器、压缩器等 + +**核心职责:** +- 管理服务注册(本地服务和代理服务) +- 路由匹配和方法查找 +- 处理 HTTP 请求到 gRPC 调用的转换 +- 管理编解码器和压缩器 +- 提供拦截器支持(Unary 和 Stream) + +### 2. RouterTree(路由树) + +`RouterTree` 基于树形结构实现高效的路径匹配: + +```go +type RouteTree struct { + nodeMap map[string]*nodeTree // 按 HTTP 方法分组的节点树 +} + +type nodeTree struct { + nodeMap map[string]*nodeTree // 子节点(路径段) + verbMap map[string]*routeTarget // 动词到路由目标的映射 +} +``` + +**特性:** +- 路径解析:使用 `participle` 解析 HTTP Rule 路径模板 +- 变量提取:支持路径变量(`{field}`)、带模式的变量(`{field=pattern}`) +- 通配符匹配:支持 `*`(单段)和 `**`(多段贪婪匹配) +- 动词支持:支持 `:verb` 后缀用于区分操作 + +### 3. Codec(编解码器) + +**内置编解码器:** +- `CodecJSON`:基于 `protojson` 的 JSON 编解码 +- `CodecProto`:Protobuf 二进制格式编解码 +- `codecHTTPBody`:原始 HTTP Body 处理 + +**编解码器接口:** +```go +type Codec interface { + encoding.Codec + MarshalAppend([]byte, any) ([]byte, error) +} + +type StreamCodec interface { + Codec + ReadNext(buf []byte, r io.Reader, limit int) (dst []byte, n int, err error) + WriteNext(w io.Writer, src []byte) (n int, err error) +} +``` + +### 4. Stream(流处理) + +**流类型:** +- `streamHTTP`:基于 Fiber Context 的 HTTP 请求/响应流 +- `fiberWebWriter`:gRPC Web 响应流 +- `streamWebSocket`:WebSocket 双向流 +- `streamInProcess`:进程内流 +- `streamProxy`:代理流 + +### 5. ServiceWrapper 和 MethodWrapper + +**ServiceWrapper:** +- 包装 gRPC 服务描述符和实现 +- 区分本地服务和代理服务 +- 管理服务的编解码器和拦截器配置 + +**MethodWrapper:** +- 包装单个 gRPC 方法 +- 存储输入/输出消息类型 +- 关联 HTTP Rule 配置和路径变量信息 + +## 性能特性 + +- **路由匹配**:O(d) 时间复杂度,d 是路径深度 +- **进程内调用**:零网络开销,直接方法调用 +- **内存优化**:使用前缀树共享公共路径前缀 +- **并发安全**:Gateway 无状态,可安全并发处理请求 + +## 依赖关系 + +- `google.golang.org/grpc`:gRPC 核心库 +- `google.golang.org/protobuf`:Protobuf 运行时 +- `github.com/gofiber/fiber/v3`:HTTP 框架 +- `github.com/fullstorydev/grpchan/inprocgrpc`:进程内 gRPC 通道 +- `github.com/alecthomas/participle/v2`:路径模板解析器 diff --git a/pkg/gateway/docs/grpcweb.md b/pkg/gateway/docs/grpcweb.md new file mode 100644 index 000000000..fb8be099c --- /dev/null +++ b/pkg/gateway/docs/grpcweb.md @@ -0,0 +1,266 @@ +# gRPC Web 支持 + +Gateway 模块提供了完整的 gRPC Web 支持,允许浏览器直接调用 gRPC 服务,无需额外的代理服务器。 + +## 功能特性 + +- ✅ 支持 `application/grpc-web+proto` (二进制格式,推荐) +- ✅ 支持 `application/grpc-web-text+proto` (Base64 文本格式) +- ✅ 支持 `application/grpc-web+json` (JSON 格式) +- ✅ 自动处理 gRPC 帧格式 +- ✅ 正确返回 Trailer (grpc-status) +- ✅ 与 Fiber 框架无缝集成 +- ✅ 兼容 protobuf-ts、grpc-web、Connect-Web 等主流客户端库 + +## 快速开始 + +### 1. 服务端配置 + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/cors" + "github.com/pubgo/lava/v2/pkg/gateway" + + pb "your/proto/package" +) + +// 实现 gRPC 服务 +type greeterService struct { + pb.UnimplementedGreeterServiceServer +} + +func (s *greeterService) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloResponse, error) { + return &pb.HelloResponse{ + Message: "Hello, " + req.GetName() + "!", + Timestamp: time.Now().Unix(), + }, nil +} + +func main() { + // 创建 Gateway Mux + mux := gateway.NewMux() + + // 注册服务 + mux.RegisterService(&pb.GreeterService_ServiceDesc, &greeterService{}) + + // 创建 Fiber 应用 + app := fiber.New() + + // 添加 CORS 中间件 (gRPC Web 必需) + app.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "OPTIONS"}, + AllowHeaders: []string{"Content-Type", "X-Grpc-Web", "X-User-Agent", "Grpc-Timeout"}, + ExposeHeaders: []string{"Grpc-Status", "Grpc-Message", "Grpc-Status-Details-Bin"}, + })) + + // 注册 HTTP/JSON 路由 (REST API) + app.All("/v1/*", mux.Handler) + + // 注册 gRPC Web 路由 + // 路由格式: /./ + app.Post("/example.v1.GreeterService/*", mux.Handler) + + log.Fatal(app.Listen(":8080")) +} +``` + +### 2. 前端集成 (protobuf-ts) + +**安装依赖:** + +```bash +npm install @protobuf-ts/plugin @protobuf-ts/grpcweb-transport +``` + +**生成 TypeScript 代码:** + +```bash +npx protoc --ts_out ./src/generated --proto_path ./proto ./proto/greeter.proto +``` + +**使用客户端:** + +```typescript +import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'; +import { GreeterServiceClient } from './generated/greeter.client'; + +// 创建 Transport +const transport = new GrpcWebFetchTransport({ + baseUrl: 'http://localhost:8080', + format: 'binary', // 推荐使用二进制格式 +}); + +// 创建客户端 +const client = new GreeterServiceClient(transport); + +// 调用方法 +async function sayHello(name: string) { + try { + const call = client.sayHello({ name }); + const response = await call.response; + + console.log('Message:', response.message); + console.log('Timestamp:', response.timestamp); + + // 获取状态 + const status = await call.status; + console.log('Status:', status.code); // "OK" + } catch (error) { + console.error('gRPC Error:', error); + } +} + +sayHello('World'); +``` + +## Content-Type 说明 + +Gateway 通过 `Content-Type` 头判断请求类型: + +| Content-Type | 描述 | +| --------------------------------- | -------------------------- | +| `application/grpc-web+proto` | gRPC Web 二进制格式 (推荐) | +| `application/grpc-web-text+proto` | gRPC Web Base64 文本格式 | +| `application/grpc-web+json` | gRPC Web JSON 格式 | +| `application/json` | 普通 HTTP/JSON (REST API) | + +## gRPC Web 协议格式 + +### 请求格式 + +``` +[1 byte: compression flag] [4 bytes: message length (big-endian)] [message bytes] +``` + +- Compression flag: `0x00` = 无压缩 +- Message length: 4 字节大端整数 +- Message: Protobuf 编码的消息 + +### 响应格式 + +响应由数据帧和 Trailer 帧组成: + +``` +数据帧: [0x00] [4 bytes: length] [protobuf message] +Trailer: [0x80] [4 bytes: length] [HTTP headers format] +``` + +Trailer 示例: `Grpc-Status: 0\r\n` + +## CORS 配置 + +gRPC Web 是跨域请求,必须配置 CORS: + +```go +app.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // 生产环境应该限制具体域名 + AllowMethods: []string{"GET", "POST", "OPTIONS"}, + AllowHeaders: []string{"Content-Type", "X-Grpc-Web", "X-User-Agent", "Grpc-Timeout", "Authorization"}, + ExposeHeaders: []string{"Grpc-Status", "Grpc-Message", "Grpc-Status-Details-Bin"}, +})) +``` + +## 与其他客户端库兼容 + +### grpc-web (Google 官方) + +```typescript +import { GreeterServiceClient } from './generated/greeter_grpc_web_pb'; + +const client = new GreeterServiceClient('http://localhost:8080'); + +client.sayHello({ name: 'World' }, {}, (err, response) => { + if (err) { + console.error(err); + return; + } + console.log(response.getMessage()); +}); +``` + +### Connect-Web + +```typescript +import { createPromiseClient } from "@connectrpc/connect"; +import { createGrpcWebTransport } from "@connectrpc/connect-web"; +import { GreeterService } from "./generated/greeter_connect"; + +const transport = createGrpcWebTransport({ + baseUrl: "http://localhost:8080", +}); + +const client = createPromiseClient(GreeterService, transport); + +const response = await client.sayHello({ name: "World" }); +console.log(response.message); +``` + +## 调试方法 + +### 使用 curl 测试 HTTP/JSON + +```bash +curl -X POST http://localhost:8080/v1/greeter/hello \ + -H "Content-Type: application/json" \ + -d '{"name": "World"}' +``` + +### 使用 curl 测试 gRPC Web + +```bash +# 发送请求并查看原始响应 +printf '\x00\x00\x00\x00\x07\n\x05World' | \ +curl -X POST http://localhost:8080/example.v1.GreeterService/SayHello \ + -H "Content-Type: application/grpc-web+proto" \ + --data-binary @- \ + --output - | xxd + +# 查看响应头 +curl -X POST http://localhost:8080/example.v1.GreeterService/SayHello \ + -H "Content-Type: application/grpc-web+proto" \ + --data-binary @- -i +``` + +## 完整示例 + +参见 `internal/examples/grpcweb/` 目录: + +``` +internal/examples/grpcweb/ +├── proto/ +│ └── greeter.proto # Proto 定义 +├── main.go # Go 服务端实现 +├── static/ # 编译后的前端静态文件 +└── frontend/ # TypeScript 前端源码 + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + ├── index.html + └── src/ + ├── main.ts # 客户端代码 + └── generated/ # 生成的 TypeScript 代码 +``` + +**运行示例:** + +```bash +# 启动服务端 +go run ./internal/examples/grpcweb/ + +# 访问测试页面 +open http://localhost:8080/ +``` + +## 当前限制 + +- ✅ 已支持 Unary 与 **服务端响应流(Server Streaming)** +- ⏳ 暂不支持客户端流(Client Streaming)与双向流(Bidi Streaming) +- ⏳ 暂不支持 WebSocket 升级 diff --git a/pkg/gateway/docs/internals.md b/pkg/gateway/docs/internals.md new file mode 100644 index 000000000..82d5debce --- /dev/null +++ b/pkg/gateway/docs/internals.md @@ -0,0 +1,237 @@ +# Gateway 实现细节 + +本文档介绍 Gateway 模块的内部实现细节,适合需要深入了解或扩展 Gateway 的开发者。 + +## 路径解析 + +Gateway 使用 [participle](https://github.com/alecthomas/participle) 解析 HTTP Rule 路径模板。 + +### 解析流程 + +1. **词法分析**:将路径字符串分解为 tokens +2. **语法解析**:根据 HTTP Rule 语法规则构建路径 AST +3. **路径规范化**:提取路径变量、通配符、动词等信息 +4. **路由树构建**:将解析后的路径信息添加到路由树中 + +### 支持的路径元素 + +- 字面量路径段(如 `/v1/users`) +- 路径变量(`{field}` 或 `{field.subfield}`) +- 带模式的路径变量(`{field=pattern}`) +- 单段通配符(`*`) +- 多段通配符(`**`,贪婪匹配) +- 动词(`:verb`,如 `/users/{id}:get`) + +### 匹配优先级 + +1. **精确匹配**:完全匹配的路径段优先 +2. **路径变量**:`{field}` 形式的变量匹配 +3. **单段通配符**:`*` 匹配单个路径段 +4. **多段通配符**:`**` 贪婪匹配剩余所有路径段 + +### 性能特性 + +- **时间复杂度**:O(d),d 是路径深度 +- **空间复杂度**:O(n),n 是路由数量 +- **内存优化**:使用前缀树共享公共路径前缀 + +## 进程内调用 + +Gateway 使用 [inprocgrpc](https://github.com/fullstorydev/grpchan) 实现进程内的 gRPC 调用。 + +### 优势 + +- **零网络开销**:进程内直接调用 +- **类型安全**:编译时类型检查 +- **调试友好**:可以直接调试和堆栈跟踪 +- **性能最优**:避免网络延迟和协议开销 + +### 实现方式 + +```go +localClient := new(inprocgrpc.Channel) +localClient.RegisterService(sd, ss) // 注册服务到进程内通道 +``` + +## 查询参数处理 + +### 处理流程 + +1. 从 HTTP 请求 URL 中提取查询参数 +2. 合并路径变量 +3. 根据字段名和 JSON 名称匹配 Protobuf 字段 +4. 执行类型转换 +5. 处理数组参数 + +### 特性 + +- **嵌套字段支持**:`user.profile.name` +- **类型自动转换**:string → int/bool/double +- **数组参数**:`?ids=1&ids=2&ids=3` +- **默认值处理**:支持 Protobuf 默认值 + +## 元数据转换 + +### HTTP → gRPC + +- 过滤保留的 HTTP 头(`Content-Type`、`User-Agent`、`grpc-*`) +- 将 HTTP 头转换为小写的 gRPC metadata key +- 处理二进制元数据(`-bin` 后缀,base64 解码) + +### gRPC → HTTP + +- 将 gRPC header/trailer 转换为 HTTP 响应头 +- 二进制元数据使用 base64 编码,添加 `-bin` 后缀 + +### 保留的 HTTP 头 + +- `Content-Type` +- `User-Agent` +- `grpc-*` 相关的头 + +## 错误码映射 + +完整的 gRPC 错误码到 HTTP 状态码映射: + +| gRPC Code | HTTP Status | 说明 | +|-----------|-------------|------| +| OK | 200 | 成功 | +| Canceled | 499 | 客户端取消 | +| Unknown | 500 | 未知错误 | +| InvalidArgument | 400 | 无效参数 | +| DeadlineExceeded | 504 | 超时 | +| NotFound | 404 | 未找到 | +| AlreadyExists | 409 | 已存在 | +| PermissionDenied | 403 | 权限不足 | +| ResourceExhausted | 429 | 资源耗尽 | +| FailedPrecondition | 400 | 前置条件失败 | +| Aborted | 409 | 被中止 | +| OutOfRange | 400 | 超出范围 | +| Unimplemented | 501 | 未实现 | +| Internal | 500 | 内部错误 | +| Unavailable | 503 | 服务不可用 | +| DataLoss | 500 | 数据丢失 | +| Unauthenticated | 401 | 未认证 | + +## gRPC Web 实现 + +### 协议检测 + +通过 `Content-Type` 头检测 gRPC Web 请求: + +```go +func isWebRequestFromContentType(ct, method string) (typ string, enc string, ok bool) { + if !strings.HasPrefix(ct, "application/grpc-web") || method != http.MethodPost { + return "", "", false + } + typ, enc, ok = strings.Cut(ct, "+") + if !ok { + enc = "proto" + } + ok = typ == grpcWeb || typ == grpcWebText + return typ, enc, ok +} +``` + +### fiberWebWriter + +专门为 Fiber 框架设计的 gRPC Web 响应写入器: + +```go +type fiberWebWriter struct { + ctx *fiber.Ctx + resp io.Writer + typ string // grpcWeb or grpcWebText + enc string // proto or json + wroteHeader bool + wroteResp bool +} +``` + +**主要功能:** +- 设置正确的 `Content-Type` 响应头 +- 写入 gRPC 数据帧 +- 写入 Trailer 帧(包含 grpc-status) + +### Trailer 帧格式 + +```go +func (w *fiberWebWriter) writeTrailer() error { + // 收集 grpc-* headers + tr := make(http.Header) + w.ctx.Response().Header.VisitAll(func(key, value []byte) { + k := string(key) + if strings.HasPrefix(strings.ToLower(k), "grpc-") { + tr[strings.ToLower(k)] = []string{string(value)} + } + }) + // 默认 grpc-status + if tr.Get("grpc-status") == "" { + tr.Set("grpc-status", "0") + } + + // 写入 trailer 帧 + head := []byte{1 << 7, 0, 0, 0, 0} // MSB=1 表示 trailer + binary.BigEndian.PutUint32(head[1:5], uint32(buf.Len())) + w.resp.Write(head) + w.resp.Write(buf.Bytes()) +} +``` + +## 流式处理实现 + +### streamHTTP + +基于 Fiber Context 的 HTTP 请求/响应流: + +**RecvMsg 流程:** +1. 检查 HTTP 方法是否允许请求体 +2. 定位请求体字段(根据 body 规则) +3. 执行请求拦截器 +4. 解析 gRPC 帧(如果是 gRPC Content-Type) +5. JSON/Protobuf 解码 +6. 合并路径变量和查询参数 + +**SendMsg 流程:** +1. 定位响应字段(根据 response_body 规则) +2. 执行响应拦截器 +3. JSON/Protobuf 编码 +4. 添加 gRPC 帧头(如果是 gRPC Content-Type) +5. 写入响应 + +### gRPC 帧格式 + +``` +[1 byte: flags] [4 bytes: length (big-endian)] [message bytes] +``` + +- flags: `0x00` = 数据帧,`0x80` = trailer 帧 +- length: 消息长度(大端序) +- message: 实际的 protobuf 消息 + +## FieldMask 支持 + +### FieldMaskFromRequestBody + +从 JSON 请求体自动生成 FieldMask: + +```go +func FieldMaskFromRequestBody(r io.Reader, msg proto.Message) (*fieldmaskpb.FieldMask, error) +``` + +**特性:** +- 支持嵌套字段路径 +- 自动处理 `google.protobuf.Struct` +- 支持 `google.protobuf.Any` 类型 + +**使用场景:** +- 部分字段更新(PATCH 请求) +- 减少响应数据量 +- GraphQL 风格的字段选择 + +## 参考资料 + +- [Google API HTTP Annotation](https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api) +- [gRPC Gateway](https://github.com/grpc-ecosystem/grpc-gateway) +- [gRPC Web Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) +- [AIP-123: Resource-oriented design](https://google.aip.dev/123) diff --git a/pkg/gateway/docs/usage.md b/pkg/gateway/docs/usage.md new file mode 100644 index 000000000..3d9ed6aed --- /dev/null +++ b/pkg/gateway/docs/usage.md @@ -0,0 +1,315 @@ +# Gateway 使用指南 + +本文档介绍如何使用 Gateway 模块,包括服务注册、路由配置、中间件等。 + +## 快速开始 + +### 1. 定义 Protobuf 服务 + +```protobuf +syntax = "proto3"; + +package example.v1; + +import "google/api/annotations.proto"; + +service UserService { + rpc GetUser(GetUserRequest) returns (User) { + option (google.api.http) = { + get: "/v1/users/{user_id}" + }; + } + + rpc CreateUser(CreateUserRequest) returns (User) { + option (google.api.http) = { + post: "/v1/users" + body: "user" + }; + } +} + +message GetUserRequest { + string user_id = 1; +} + +message CreateUserRequest { + User user = 1; +} + +message User { + string id = 1; + string name = 2; + string email = 3; +} +``` + +### 2. 实现服务 + +```go +type userService struct { + pb.UnimplementedUserServiceServer +} + +func (s *userService) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) { + return &pb.User{ + Id: req.UserId, + Name: "John Doe", + Email: "john@example.com", + }, nil +} + +func (s *userService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) { + return req.User, nil +} +``` + +### 3. 配置 Gateway + +```go +package main + +import ( + "github.com/gofiber/fiber/v3" + "github.com/pubgo/lava/v2/pkg/gateway" + pb "your/proto/package" +) + +func main() { + // 创建 Gateway + mux := gateway.NewMux() + + // 注册服务 + mux.RegisterService(&pb.UserService_ServiceDesc, &userService{}) + + // 创建 Fiber 应用 + app := fiber.New() + app.All("/v1/*", mux.Handler) + + app.Listen(":8080") +} +``` + +### 4. 测试调用 + +```bash +# GET 请求 +curl http://localhost:8080/v1/users/123 + +# POST 请求 +curl -X POST http://localhost:8080/v1/users \ + -H "Content-Type: application/json" \ + -d '{"user": {"name": "Jane", "email": "jane@example.com"}}' +``` + +## 服务注册 + +### 本地服务 + +本地服务运行在同一进程中,通过进程内通道调用: + +```go +mux := gateway.NewMux() +mux.RegisterService(&pb.UserService_ServiceDesc, &userServiceImpl{}) +``` + +### 代理服务 + +代理服务将请求转发到远程 gRPC 服务: + +```go +// 连接到远程服务 +conn, _ := grpc.Dial("remote-service:50051", grpc.WithInsecure()) + +// 注册代理 +mux.RegisterProxy(&pb.UserService_ServiceDesc, router, conn) +``` + +## HTTP Rule 配置 + +### 请求体映射 + +```protobuf +// 整个消息作为请求体 +rpc CreateUser(CreateUserRequest) returns (User) { + option (google.api.http) = { + post: "/v1/users" + body: "*" + }; +} + +// 指定字段作为请求体 +rpc UpdateUser(UpdateUserRequest) returns (User) { + option (google.api.http) = { + patch: "/v1/users/{user_id}" + body: "user" // 只有 user 字段从请求体解析 + }; +} +``` + +### 响应体映射 + +```protobuf +// 只返回指定字段 +rpc GetUser(GetUserRequest) returns (GetUserResponse) { + option (google.api.http) = { + get: "/v1/users/{user_id}" + response_body: "user" // 只返回 user 字段 + }; +} +``` + +### 多路由绑定 + +```protobuf +rpc UpdateUser(UpdateUserRequest) returns (User) { + option (google.api.http) = { + patch: "/v1/users/{user.id}" + body: "user" + additional_bindings: { + put: "/v1/users/{user.id}" + body: "user" + } + }; +} +``` + +## 路径匹配规则 + +| 模式 | 示例 | 说明 | +|------|------|------| +| `{field}` | `/users/{id}` | 匹配单个路径段 | +| `{field.subfield}` | `/users/{user.id}` | 嵌套字段 | +| `{field=pattern}` | `{id=projects/*/users/*}` | 带模式的变量 | +| `*` | `/files/*` | 单段通配符 | +| `**` | `/files/**` | 多段通配符 | +| `:verb` | `/users/{id}:get` | 动词后缀 | + +## 中间件 + +### Unary 拦截器 + +```go +mux.SetUnaryInterceptor(func( + ctx context.Context, + req interface{}, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (interface{}, error) { + // 前置处理(日志、认证等) + start := time.Now() + + // 调用实际处理 + resp, err := handler(ctx, req) + + // 后置处理(记录耗时等) + log.Printf("Method: %s, Duration: %v", info.FullMethod, time.Since(start)) + + return resp, err +}) +``` + +### Stream 拦截器 + +```go +mux.SetStreamInterceptor(func( + srv interface{}, + ss grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, +) error { + log.Printf("Starting stream: %s", info.FullMethod) + return handler(srv, ss) +}) +``` + +## 请求/响应拦截器 + +### 请求解码器 + +```go +mux.SetRequestDecoder( + protoreflect.FullName("example.UserRequest"), + func(ctx *fiber.Ctx, msg proto.Message) error { + // 从 HTTP 头读取用户信息 + userID := ctx.Get("X-User-ID") + if userID != "" { + msg.ProtoReflect().Set( + msg.ProtoReflect().Descriptor().Fields().ByName("user_id"), + protoreflect.ValueOfString(userID), + ) + } + return nil + }, +) +``` + +### 响应编码器 + +```go +mux.SetResponseEncoder( + protoreflect.FullName("example.UserResponse"), + func(ctx *fiber.Ctx, msg proto.Message) error { + // 添加自定义响应头 + ctx.Set("X-Custom-Header", "value") + return nil + }, +) +``` + +## 配置选项 + +```go +mux := gateway.NewMux( + // 最大接收消息大小(默认 4MB) + gateway.MaxReceiveMessageSizeOption(4 * 1024 * 1024), + + // 最大发送消息大小 + gateway.MaxSendMessageSizeOption(4 * 1024 * 1024), + + // 连接超时(默认 120s) + gateway.ConnectionTimeoutOption(120 * time.Second), + + // 自定义编解码器 + gateway.CodecOption("application/xml", xmlCodec), + + // 自定义压缩器 + gateway.CompressorOption("gzip", gzipCompressor), +) +``` + +## 错误处理 + +Gateway 自动将 gRPC 错误码映射为 HTTP 状态码: + +| gRPC Code | HTTP Status | +|-----------|-------------| +| OK | 200 | +| InvalidArgument | 400 | +| Unauthenticated | 401 | +| PermissionDenied | 403 | +| NotFound | 404 | +| AlreadyExists | 409 | +| ResourceExhausted | 429 | +| Internal | 500 | +| Unavailable | 503 | + +在服务中返回 gRPC 错误: + +```go +import "google.golang.org/grpc/status" + +func (s *userService) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) { + if req.UserId == "" { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + // ... +} +``` + +## 最佳实践 + +1. **使用 HTTP Rule 注解**:在 Protobuf 中定义路由,而不是手动注册 +2. **合理使用 body 映射**:只映射需要的字段,减少数据传输 +3. **使用拦截器**:统一处理日志、认证、监控等横切关注点 +4. **错误处理**:使用标准的 gRPC 错误码,Gateway 会自动映射 +5. **进程内调用**:优先使用本地服务注册,避免网络开销 diff --git a/pkg/gateway/grpccodes.go b/pkg/gateway/grpccodes.go index 383908b75..6bc13bf5f 100644 --- a/pkg/gateway/grpccodes.go +++ b/pkg/gateway/grpccodes.go @@ -9,33 +9,6 @@ import ( // https://github.com/dapr/kit/tree/main/grpccodes -var codeToHTTPStatus = [...]int{ - http.StatusOK, // 0 - http.StatusRequestTimeout, // 1 - http.StatusInternalServerError, // 2 - http.StatusBadRequest, // 3 - http.StatusGatewayTimeout, // 4 - http.StatusNotFound, // 5 - http.StatusConflict, // 6 - http.StatusForbidden, // 7 - http.StatusTooManyRequests, // 8 - http.StatusBadRequest, // 9 - http.StatusConflict, // 10 - http.StatusBadRequest, // 11 - http.StatusNotImplemented, // 12 - http.StatusInternalServerError, // 13 - http.StatusServiceUnavailable, // 14 - http.StatusInternalServerError, // 15 - http.StatusUnauthorized, // 16 -} - -func HTTPStatusCode(c codes.Code) int { - if int(c) > len(codeToHTTPStatus) { - return http.StatusInternalServerError - } - return codeToHTTPStatus[c] -} - // HTTPStatusFromCode converts a gRPC error code into the corresponding HTTP response status. // See: https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto func HTTPStatusFromCode(code codes.Code) int { diff --git a/pkg/gateway/grpcweb_test.go b/pkg/gateway/grpcweb_test.go new file mode 100644 index 000000000..82f8e29b6 --- /dev/null +++ b/pkg/gateway/grpcweb_test.go @@ -0,0 +1,32 @@ +package gateway + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsWebRequest(t *testing.T) { + tests := []struct { + name string + contentType string + method string + expected bool + }{ + {"valid grpc-web", "application/grpc-web+proto", "POST", true}, + {"valid grpc-web-text", "application/grpc-web-text+json", "POST", true}, + {"invalid method", "application/grpc-web+proto", "GET", false}, + {"invalid content-type", "application/json", "POST", false}, + {"no subtype", "application/grpc-web", "POST", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, "/", nil) + req.Header.Set("Content-Type", tt.contentType) + _, _, ok := isWebRequest(req) + assert.Equal(t, tt.expected, ok) + }) + } +} diff --git a/pkg/gateway/mux.go b/pkg/gateway/mux.go index 2d20a3643..8ce40fbde 100644 --- a/pkg/gateway/mux.go +++ b/pkg/gateway/mux.go @@ -2,23 +2,23 @@ package gateway import ( "context" + "encoding/base64" "fmt" + "io" "net/http" "net/url" "reflect" "strings" "github.com/fullstorydev/grpchan/inprocgrpc" - "github.com/gofiber/adaptor/v2" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/pubgo/funk/v2" "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/log" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" - "github.com/rs/zerolog" "github.com/samber/lo" "google.golang.org/grpc" "google.golang.org/grpc/metadata" @@ -38,8 +38,8 @@ type muxOptions struct { codecs map[string]Codec codecsByName map[string]Codec compressors map[string]Compressor - requestInterceptors map[protoreflect.FullName]func(ctx *fiber.Ctx, msg proto.Message) error - responseInterceptors map[protoreflect.FullName]func(ctx *fiber.Ctx, msg proto.Message) error + requestInterceptors map[protoreflect.FullName]func(ctx fiber.Ctx, msg proto.Message) error + responseInterceptors map[protoreflect.FullName]func(ctx fiber.Ctx, msg proto.Message) error handlers map[string]*methodWrapper customOperationNames map[string]*methodWrapper } @@ -51,8 +51,8 @@ var ( defaultMuxOptions = muxOptions{ files: protoregistry.GlobalFiles, types: protoregistry.GlobalTypes, - responseInterceptors: make(map[protoreflect.FullName]func(ctx *fiber.Ctx, msg proto.Message) error), - requestInterceptors: make(map[protoreflect.FullName]func(ctx *fiber.Ctx, msg proto.Message) error), + responseInterceptors: make(map[protoreflect.FullName]func(ctx fiber.Ctx, msg proto.Message) error), + requestInterceptors: make(map[protoreflect.FullName]func(ctx fiber.Ctx, msg proto.Message) error), handlers: make(map[string]*methodWrapper), customOperationNames: make(map[string]*methodWrapper), } @@ -80,20 +80,20 @@ type Mux struct { func (m *Mux) GetRouteMethods() []RouteOperation { return m.routerTree.List() } -func (m *Mux) SetResponseEncoder(name protoreflect.FullName, f func(ctx *fiber.Ctx, msg proto.Message) error) { +func (m *Mux) SetResponseEncoder(name protoreflect.FullName, f func(ctx fiber.Ctx, msg proto.Message) error) { m.opts.responseInterceptors[name] = f } -func (m *Mux) SetRequestDecoder(name protoreflect.FullName, f func(ctx *fiber.Ctx, msg proto.Message) error) { +func (m *Mux) SetRequestDecoder(name protoreflect.FullName, f func(ctx fiber.Ctx, msg proto.Message) error) { m.opts.requestInterceptors[name] = f } func (m *Mux) MatchOperation(method, path string) (r result.Result[*MatchOperation]) { return result.Wrap(m.routerTree.Match(method, path)). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("method", method) e.Str("path", path) - e.Str(logfields.Msg, "match operation failed") + e.Msg("match operation failed") }) } @@ -115,10 +115,133 @@ func (m *Mux) GetOperation(operation string) *GrpcMethod { return handleOperation(opt) } -func (m *Mux) Handler(ctx *fiber.Ctx) error { +func (m *Mux) Handler(ctx fiber.Ctx) error { + // Check if this is a gRPC Web request + ct := string(ctx.Request().Header.ContentType()) + if typ, enc, ok := isWebRequestFromContentType(ct, ctx.Method()); ok { + // TODO: Check for websocket request and upgrade. + if strings.EqualFold(ctx.Get("Upgrade"), "websocket") { + return fiber.NewError(fiber.StatusInternalServerError, "unimplemented websocket support") + } + + // Modify request for gRPC Web + ctx.Request().Header.SetContentType(grpcBase + "+" + enc) + if typ == grpcWebText { + // gRPC-Web-Text (Base64) 解码处理 + // 策略: + // 1. 如果是 Stream 模式 (Fasthttp BodyStream != nil),则包裹 Stream 进行流式解码。 + // 2. 如果是 Buffer 模式 (Body 已经在内存中),则直接对 Body 进行解码并回写。 + + inputStream := ctx.Request().BodyStream() + if inputStream != nil { + // 流式处理 + body := base64.NewDecoder(base64.StdEncoding, inputStream) + rc := &readCloser{ + Reader: body, + Closer: io.NopCloser(nil), + } + ctx.Request().SetBodyStream(rc, -1) + } else { + // 非流式处理,直接操作 Body 字节 + originBody := ctx.Body() + if len(originBody) > 0 { + // Base64 解码需要分配新内存,这在普通请求中是可接受的 + // 计算解码后长度 + dbuf := make([]byte, base64.StdEncoding.DecodedLen(len(originBody))) + n, err := base64.StdEncoding.Decode(dbuf, originBody) + if err == nil { + ctx.Request().SetBody(dbuf[:n]) + } else { + // 如果解码失败,这里暂时无法中止 Handler,只能留给后续 Protobuf Unmarshal 报错 + // 但至少不能 Panic + log.Err(err). + Stack(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("base64 decode failed") + return errors.Errorf("base64 decode failed, method=%s path=%s", ctx.Method(), string(ctx.Request().URI().Path())) + } + } + } + } + + // Create Fiber-specific web writer + ww := newFiberWebWriter(ctx, typ, enc) + + // Continue with normal processing but capture the response + matchOperation, err := m.routerTree.Match(ctx.Method(), string(ctx.Request().URI().Path())) + if err != nil { + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("match operation failed") + return errors.Errorf("match operation failed, method=%s path=%s", ctx.Method(), string(ctx.Request().URI().Path())) + } + + values := make(url.Values) + for _, v := range matchOperation.Vars { + values.Set(strings.Join(v.Fields, "."), v.Value) + } + + for k, v := range ctx.Queries() { + values.Set(k, v) + } + + mth := m.opts.handlers[matchOperation.Operation] + if mth == nil { + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("method operation not found") + return errors.Errorf("method operation not found, method=%s path=%s", matchOperation.Operation, ctx.Request().URI().Path()) + } + + md := metadata.MD{} + for k, v := range ctx.GetReqHeaders() { + md.Append(k, v...) + } + + stream := &streamHTTP{ + handler: ctx, + ctx: metadata.NewIncomingContext(ctx.Context(), md), + method: mth, + params: values, + path: matchOperation, + writer: ww, + } + + in := mth.inputType.New().Interface() + err = stream.RecvMsg(in) + if err != nil { + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("unmarshal request failed") + return errors.Errorf("unmarshal request failed, method=%s", matchOperation.Operation) + } + + ctx.Set(httputil.HeaderXRequestVersion, version.Version()) + ctx.Set(httputil.HeaderXRequestOperation, matchOperation.Operation) + + err = m.invokeWithStream(stream, in) + if err != nil { + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("invoke failed") + return errors.Errorf("invoke failed, method=%s", matchOperation.Operation) + } + ww.flushWithTrailer() + return nil + } + matchOperation, err := m.routerTree.Match(ctx.Method(), string(ctx.Request().URI().Path())) if err != nil { - return errors.WrapCaller(err) + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("match operation failed") + return errors.Errorf("match operation failed, method=%s path=%s", ctx.Method(), string(ctx.Request().URI().Path())) } values := make(url.Values) @@ -132,6 +255,10 @@ func (m *Mux) Handler(ctx *fiber.Ctx) error { mth := m.opts.handlers[matchOperation.Operation] if mth == nil { + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("method operation not found") return errors.Errorf("method operation not found, method=%s", matchOperation.Operation) } @@ -151,39 +278,146 @@ func (m *Mux) Handler(ctx *fiber.Ctx) error { in := mth.inputType.New().Interface() err = stream.RecvMsg(in) if err != nil { + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("unmarshal request failed") + return errors.Errorf("unmarshal request failed, method=%s", matchOperation.Operation) + } + err = m.invokeWithStream(stream, in) + if err != nil { + log.Error(). + Str("method", ctx.Method()). + Str("path", string(ctx.Request().URI().Path())). + Msg("invoke failed") return errors.WrapCaller(err) } + ctx.Response().Header.Set(httputil.HeaderXRequestVersion, version.Version()) + ctx.Response().Header.Set(httputil.HeaderXRequestOperation, matchOperation.Operation) + ctx.Response().Header.SetContentTypeBytes(ctx.Request().Header.ContentType()) + return nil +} + +func (m *Mux) invokeWithStream(stream *streamHTTP, in any) error { + mth := stream.method + if mth == nil { + return errors.New("method wrapper is nil") + } + + if mth.grpcStreamDesc != nil { + return m.invokeResponseStream(stream, in) + } + out := mth.outputType.New().Interface() var header metadata.MD var trailer metadata.MD - err = m.Invoke(stream.ctx, mth.grpcFullMethod, in, out, grpc.Header(&header), grpc.Trailer(&trailer)) + if err := m.Invoke(stream.ctx, mth.grpcFullMethod, in, out, grpc.Header(&header), grpc.Trailer(&trailer)); err != nil { + return err + } + + applyResponseMetadata(stream.handler, header) + applyResponseMetadata(stream.handler, trailer) + + return stream.SendMsg(out) +} + +func (m *Mux) invokeResponseStream(remoteStream *streamHTTP, in any) error { + mth := remoteStream.method + if mth == nil || mth.grpcStreamDesc == nil { + return errors.New("stream method descriptor is nil") + } + + if !mth.grpcStreamDesc.ServerStreams { + return errors.Errorf("unsupported stream mode: %s is not server-streaming", mth.grpcFullMethod) + } + if mth.grpcStreamDesc.ClientStreams { + return errors.Errorf("unsupported stream mode: %s has client-streaming", mth.grpcFullMethod) + } + + remoteStream.responseStream = true + + localStream, err := m.NewStream(remoteStream.ctx, mth.grpcStreamDesc, mth.grpcFullMethod) if err != nil { return errors.WrapCaller(err) } - hh := make(metadata.MD) - for k, v := range header { - hh.Set(k, v...) + if err = localStream.SendMsg(in); err != nil { + return errors.WrapCaller(err) + } + if err = localStream.CloseSend(); err != nil { + return errors.WrapCaller(err) + } + + headerSent := false + + for { + out := mth.outputType.New().Interface() + err = localStream.RecvMsg(out) + if err == io.EOF { + break + } + if err != nil { + return errors.WrapCaller(err) + } + + if !headerSent { + if header, headerErr := localStream.Header(); headerErr == nil { + if sendErr := remoteStream.SendHeader(header); sendErr != nil { + if !isDuplicateHeaderError(sendErr) { + return errors.WrapCaller(sendErr) + } + log.Err(sendErr). + Str("method", mth.grpcFullMethod). + Msg("ignore duplicate response-stream header send error") + } + } + headerSent = true + } + + if err = remoteStream.SendMsg(out); err != nil { + return errors.WrapCaller(err) + } } - for k, v := range trailer { - hh.Set(k, v...) + if !headerSent { + if header, headerErr := localStream.Header(); headerErr == nil { + if sendErr := remoteStream.SendHeader(header); sendErr != nil { + if !isDuplicateHeaderError(sendErr) { + return errors.WrapCaller(sendErr) + } + log.Err(sendErr). + Str("method", mth.grpcFullMethod). + Msg("ignore duplicate response-stream header send error") + } + } } - for k, v := range hh { + remoteStream.SetTrailer(localStream.Trailer()) + applyResponseMetadata(remoteStream.handler, remoteStream.trailer) + + return nil +} + +func applyResponseMetadata(ctx fiber.Ctx, md metadata.MD) { + for k, v := range md { v = lo.Filter(v, func(item string, index int) bool { return item != "" }) if len(v) == 0 { continue } - ctx.Response().Header.Set(k, v[0]) } +} - ctx.Response().Header.Set(httputil.HeaderXRequestVersion, version.Version()) - ctx.Response().Header.Set(httputil.HeaderXRequestOperation, matchOperation.Operation) - ctx.Response().Header.SetContentTypeBytes(ctx.Request().Header.ContentType()) - return errors.WrapCaller(stream.SendMsg(out)) +func isDuplicateHeaderError(err error) bool { + if err == nil { + return false + } + + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "headers already sent") || + strings.Contains(msg, "sendheader called multiple times") || + strings.Contains(msg, "header already sent") } func (m *Mux) Invoke(ctx context.Context, method string, args, reply any, opts ...grpc.CallOption) error { @@ -207,6 +441,8 @@ func (m *Mux) NewStream(ctx context.Context, desc *grpc.StreamDesc, method strin } func (m *Mux) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + // ServeHTTP acts as a thin wrapper only. + // All protocol/business handling is centralized in Handler. adaptor.FiberHandler(m.Handler).ServeHTTP(writer, request) } diff --git a/pkg/gateway/mux_stream_test.go b/pkg/gateway/mux_stream_test.go new file mode 100644 index 000000000..f302a7e1d --- /dev/null +++ b/pkg/gateway/mux_stream_test.go @@ -0,0 +1,249 @@ +package gateway + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/valyala/fasthttp" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/structpb" +) + +type fakeClientConn struct { + stream grpc.ClientStream +} + +func (f *fakeClientConn) Invoke(context.Context, string, any, any, ...grpc.CallOption) error { + return nil +} + +func (f *fakeClientConn) NewStream(context.Context, *grpc.StreamDesc, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return f.stream, nil +} + +type fakeClientStream struct { + header metadata.MD + trailer metadata.MD + frames []proto.Message + readIdx int + closed bool + sentReqs []proto.Message + headerCalled bool + earlyHeaderFetch bool + failOnEarlyHead bool +} + +func (f *fakeClientStream) Header() (metadata.MD, error) { + f.headerCalled = true + if f.readIdx == 0 { + f.earlyHeaderFetch = true + } + return f.header, nil +} + +func (f *fakeClientStream) Trailer() metadata.MD { + return f.trailer +} + +func (f *fakeClientStream) CloseSend() error { + f.closed = true + return nil +} + +func (f *fakeClientStream) Context() context.Context { + return context.Background() +} + +func (f *fakeClientStream) SendMsg(m any) error { + if pm, ok := m.(proto.Message); ok { + f.sentReqs = append(f.sentReqs, proto.Clone(pm)) + } + return nil +} + +func (f *fakeClientStream) RecvMsg(m any) error { + if f.failOnEarlyHead && f.earlyHeaderFetch { + return io.ErrUnexpectedEOF + } + + if f.readIdx >= len(f.frames) { + return io.EOF + } + pm, ok := m.(proto.Message) + if !ok { + return io.EOF + } + frame := f.frames[f.readIdx] + f.readIdx++ + b, err := proto.Marshal(frame) + if err != nil { + return err + } + return proto.Unmarshal(b, pm) +} + +func TestInvokeResponseStream_DoesNotPrefetchHeaderBeforeFirstFrame(t *testing.T) { + mux := NewMux() + + inType, err := protoregistry.GlobalTypes.FindMessageByName("google.protobuf.Empty") + if err != nil { + t.Fatalf("find input type: %v", err) + } + outType, err := protoregistry.GlobalTypes.FindMessageByName("google.protobuf.Struct") + if err != nil { + t.Fatalf("find output type: %v", err) + } + + fakeStream := &fakeClientStream{ + header: metadata.Pairs("x-stream", "header"), + frames: []proto.Message{ + &structpb.Struct{Fields: map[string]*structpb.Value{"msg": structpb.NewStringValue("hello")}}, + }, + failOnEarlyHead: true, + } + + method := &methodWrapper{ + srv: &serviceWrapper{opts: mux.opts, remoteProxyCli: &fakeClientConn{stream: fakeStream}}, + grpcStreamDesc: &grpc.StreamDesc{ServerStreams: true, ClientStreams: false}, + grpcFullMethod: "/test.v1.StreamService/Watch", + inputType: inType, + outputType: outType, + } + mux.opts.handlers[method.grpcFullMethod] = method + + app := fiber.New() + fctx := &fasthttp.RequestCtx{} + ctx := app.AcquireCtx(fctx) + defer app.ReleaseCtx(ctx) + ctx.Request().Header.SetMethod("POST") + ctx.Request().Header.SetContentType("application/json") + + stream := &streamHTTP{handler: ctx, ctx: context.Background(), method: method} + + if err = mux.invokeResponseStream(stream, &emptypb.Empty{}); err != nil { + t.Fatalf("invokeResponseStream failed: %v", err) + } + + if !fakeStream.headerCalled { + t.Fatal("expected header to be fetched eventually") + } + if fakeStream.earlyHeaderFetch { + t.Fatal("header was prefetched before first frame") + } + + body := string(ctx.Response().Body()) + if !strings.Contains(body, "\"msg\":\"hello\"") { + t.Fatalf("unexpected response body: %q", body) + } +} + +func TestInvokeResponseStream_AllowsPreSentHeaderAndStreamsJSON(t *testing.T) { + mux := NewMux() + + inType, err := protoregistry.GlobalTypes.FindMessageByName("google.protobuf.Empty") + if err != nil { + t.Fatalf("find input type: %v", err) + } + outType, err := protoregistry.GlobalTypes.FindMessageByName("google.protobuf.Struct") + if err != nil { + t.Fatalf("find output type: %v", err) + } + + fakeStream := &fakeClientStream{ + header: metadata.Pairs("x-stream", "header"), + trailer: metadata.Pairs("grpc-status", "0"), + frames: []proto.Message{ + &structpb.Struct{Fields: map[string]*structpb.Value{"msg": structpb.NewStringValue("a")}}, + &structpb.Struct{Fields: map[string]*structpb.Value{"msg": structpb.NewStringValue("b")}}, + }, + } + + method := &methodWrapper{ + srv: &serviceWrapper{opts: mux.opts, remoteProxyCli: &fakeClientConn{stream: fakeStream}}, + grpcStreamDesc: &grpc.StreamDesc{ServerStreams: true, ClientStreams: false}, + grpcFullMethod: "/test.v1.StreamService/Watch", + inputType: inType, + outputType: outType, + } + mux.opts.handlers[method.grpcFullMethod] = method + + app := fiber.New() + fctx := &fasthttp.RequestCtx{} + ctx := app.AcquireCtx(fctx) + defer app.ReleaseCtx(ctx) + ctx.Request().Header.SetMethod("POST") + ctx.Request().Header.SetContentType("application/json") + + stream := &streamHTTP{ + handler: ctx, + ctx: context.Background(), + method: method, + } + + if err = stream.SendHeader(metadata.Pairs("x-pre", "1")); err != nil { + t.Fatalf("preset SendHeader failed: %v", err) + } + + if err = mux.invokeResponseStream(stream, &emptypb.Empty{}); err != nil { + t.Fatalf("invokeResponseStream failed: %v", err) + } + + body := string(ctx.Response().Body()) + if strings.Count(body, "\n") != 2 { + t.Fatalf("expected 2 NDJSON lines, got body=%q", body) + } + if !strings.Contains(body, "\"msg\":\"a\"") || !strings.Contains(body, "\"msg\":\"b\"") { + t.Fatalf("stream body missing expected messages: %q", body) + } + if got := string(ctx.Response().Header.Peek("x-stream")); got != "header" { + t.Fatalf("missing streamed header, got=%q", got) + } + if got := string(ctx.Response().Header.Peek("x-pre")); got != "1" { + t.Fatalf("preset header lost, got=%q", got) + } + if len(fakeStream.sentReqs) != 1 { + t.Fatalf("expected 1 request message sent, got=%d", len(fakeStream.sentReqs)) + } +} + +func TestInvokeResponseStream_RejectsClientStreamingMode(t *testing.T) { + mux := NewMux() + + inType, err := protoregistry.GlobalTypes.FindMessageByName("google.protobuf.Empty") + if err != nil { + t.Fatalf("find input type: %v", err) + } + outType, err := protoregistry.GlobalTypes.FindMessageByName("google.protobuf.Struct") + if err != nil { + t.Fatalf("find output type: %v", err) + } + + method := &methodWrapper{ + srv: &serviceWrapper{opts: mux.opts, remoteProxyCli: &fakeClientConn{stream: &fakeClientStream{}}}, + grpcStreamDesc: &grpc.StreamDesc{ServerStreams: true, ClientStreams: true}, + grpcFullMethod: "/test.v1.StreamService/Bidi", + inputType: inType, + outputType: outType, + } + + app := fiber.New() + fctx := &fasthttp.RequestCtx{} + ctx := app.AcquireCtx(fctx) + defer app.ReleaseCtx(ctx) + + stream := &streamHTTP{handler: ctx, ctx: context.Background(), method: method} + err = mux.invokeResponseStream(stream, &emptypb.Empty{}) + if err == nil { + t.Fatal("expected error for client-streaming mode, got nil") + } + if !strings.Contains(err.Error(), "client-streaming") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/gateway/stream.grpcweb.go b/pkg/gateway/stream.grpcweb.go index fa0ed3c2c..f23efeda9 100644 --- a/pkg/gateway/stream.grpcweb.go +++ b/pkg/gateway/stream.grpcweb.go @@ -1 +1,164 @@ package gateway + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "io" + "net/http" + "strings" + + "github.com/gofiber/fiber/v3" +) + +const ( + grpcBase = "application/grpc" + grpcWeb = "application/grpc-web" + grpcWebText = "application/grpc-web-text" +) + +// isWebRequest checks for gRPC Web headers. +func isWebRequest(r *http.Request) (typ, enc string, ok bool) { + ct := r.Header.Get("Content-Type") + return isWebRequestFromContentType(ct, r.Method) +} + +// isWebRequestFromContentType checks for gRPC Web headers from content type string. +func isWebRequestFromContentType(ct, method string) (typ, enc string, ok bool) { + if !strings.HasPrefix(ct, "application/grpc-web") || method != http.MethodPost { + return "", "", false + } + typ, enc, ok = strings.Cut(ct, "+") + if !ok { + enc = "proto" + } + ok = typ == grpcWeb || typ == grpcWebText + return typ, enc, ok +} + +// fiberWebWriter is a gRPC Web writer specifically for Fiber framework. +// It writes headers directly to Fiber response headers. +type fiberWebWriter struct { + ctx fiber.Ctx + resp io.Writer + flushWriter http.Flusher + typ string // grpcWeb or grpcWebText + enc string // proto or json + wroteHeader bool + wroteResp bool +} + +func newFiberWebWriter(ctx fiber.Ctx, typ, enc string) *fiberWebWriter { + raw := ctx.Response().BodyWriter() + resp := raw + if typ == grpcWebText { + resp = &base64ChunkWriter{w: resp} + } + var flusher http.Flusher + if f, ok := raw.(http.Flusher); ok { + flusher = f + } + return &fiberWebWriter{ + ctx: ctx, + typ: typ, + enc: enc, + resp: resp, + flushWriter: flusher, + } +} + +func (w *fiberWebWriter) Write(data []byte) (int, error) { + if !w.wroteHeader { + w.wroteHeader = true + // Set Content-Type header directly on Fiber response + w.ctx.Set("Content-Type", w.typ+"+"+w.enc) + } + w.wroteResp = true + return w.resp.Write(data) +} + +func (w *fiberWebWriter) writeTrailer() error { + // Write trailers only if message has been sent. + if !w.wroteResp { + return nil + } + tr := make(http.Header) + // Collect grpc-* headers for trailer + //lint:ignore SA1019 VisitAll is the only available API in this fasthttp version. + for key, value := range w.ctx.Response().Header.All() { + k := string(key) + if strings.HasPrefix(strings.ToLower(k), "grpc-") { + tr[strings.ToLower(k)] = []string{string(value)} + } + } + // Add default grpc-status if not present + if tr.Get("grpc-status") == "" { + tr.Set("grpc-status", "0") + } + var buf bytes.Buffer + if err := tr.Write(&buf); err != nil { + return err + } + head := []byte{1 << 7, 0, 0, 0, 0} // MSB=1 indicates this is a trailer data frame. + binary.BigEndian.PutUint32(head[1:5], uint32(buf.Len())) + if _, err := w.resp.Write(head); err != nil { + return err + } + if _, err := w.resp.Write(buf.Bytes()); err != nil { + return err + } + return nil +} + +func (w *fiberWebWriter) flushWithTrailer() { + // Write trailers only if message has been sent. + if w.wroteHeader || w.wroteResp { + if err := w.writeTrailer(); err != nil { + return // nothing + } + } + w.Flush() +} + +func (w *fiberWebWriter) Flush() { + if w.flushWriter != nil { + w.flushWriter.Flush() + } +} + +type base64ChunkWriter struct { + w io.Writer +} + +func (b *base64ChunkWriter) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + out := make([]byte, base64.StdEncoding.EncodedLen(len(p))) + base64.StdEncoding.Encode(out, p) + if _, err := b.w.Write(out); err != nil { + return 0, err + } + return len(p), nil +} + +type readCloser struct { + io.Reader + io.Closer +} + +func (rc *readCloser) Read(p []byte) (n int, err error) { + if rc.Reader == nil { + return 0, io.EOF + } + + return rc.Reader.Read(p) +} + +func (rc *readCloser) Close() error { + if rc.Closer == nil { + return nil + } + + return rc.Closer.Close() +} diff --git a/pkg/gateway/stream.http.go b/pkg/gateway/stream.http.go index 3935dd4c0..9f56556b9 100644 --- a/pkg/gateway/stream.http.go +++ b/pkg/gateway/stream.http.go @@ -2,12 +2,15 @@ package gateway import ( "context" + "encoding/binary" "encoding/json" "fmt" + "io" "net/http" "net/url" + "strings" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2" "github.com/pubgo/funk/v2/errors" "google.golang.org/grpc" @@ -22,29 +25,44 @@ import ( type streamHTTP struct { method *methodWrapper path *routertree.MatchOperation - handler *fiber.Ctx + handler fiber.Ctx ctx context.Context header metadata.MD trailer metadata.MD params url.Values sentHeader bool + // responseStream indicates this stream writes multiple response messages. + // For JSON transport we emit NDJSON (one JSON object per line). + responseStream bool + writer io.Writer // optional custom writer } var _ grpc.ServerStream = (*streamHTTP)(nil) func (s *streamHTTP) SetHeader(md metadata.MD) error { + s.header = metadata.Join(s.header, md) if s.sentHeader { - return errors.WrapStack(fmt.Errorf("already sent headers")) + for k, v := range md { + if len(v) == 0 { + continue + } + s.handler.Response().Header.Set(k, v[0]) + } } - s.header = metadata.Join(s.header, md) return nil } func (s *streamHTTP) SendHeader(md metadata.MD) error { + s.header = metadata.Join(s.header, md) if s.sentHeader { - return errors.WrapCaller(fmt.Errorf("already sent headers")) + for k, v := range md { + if len(v) == 0 { + continue + } + s.handler.Response().Header.Set(k, v[0]) + } + return nil } - s.header = metadata.Join(s.header, md) s.sentHeader = true for k, v := range s.header { @@ -69,6 +87,16 @@ func (s *streamHTTP) Context() context.Context { return NewContextWithServerTransportStream(s.ctx, s, s.method.grpcFullMethod) } +func isGRPCContentType(ct string) bool { + ct = strings.ToLower(strings.TrimSpace(ct)) + // Treat grpc-web-json alias as plain JSON transport for compatibility. + if strings.HasPrefix(ct, "application/grpc-web-json") { + return false + } + + return strings.HasPrefix(ct, "application/grpc") +} + func (s *streamHTTP) SendMsg(m any) error { if funk.IsNil(m) { return errors.New("stream http send msg got nil") @@ -95,13 +123,55 @@ func (s *streamHTTP) SendMsg(m any) error { return errors.Wrapf(rspInterceptor(s.handler, msg), "failed to do rsp interceptor response data by %s", reqName) } - b, err := protojson.Default.Marshal(msg) + ct := string(s.handler.Request().Header.ContentType()) + isGRPC := isGRPCContentType(ct) + + var b []byte + var err error + if isGRPC { + b, err = proto.Marshal(msg) + if err != nil { + return errors.Wrap(err, "failed to marshal response by protobuf") + } + // Add gRPC frame header: compression(0) + message type(0) + length + frame := make([]byte, 5+len(b)) + binary.BigEndian.PutUint32(frame[1:5], uint32(len(b))) + copy(frame[5:], b) + b = frame + } else { + b, err = protojson.Default.Marshal(msg) + if err != nil { + return errors.Wrap(err, "failed to marshal response by protojson") + } + } + + if s.writer != nil { + _, err = s.writer.Write(b) + } else { + _, err = s.handler.Write(b) + } if err != nil { - return errors.Wrap(err, "failed to marshal response by protojson") + return errors.WrapCaller(err) } - _, err = s.handler.Write(b) - return errors.WrapCaller(err) + if !isGRPC && s.responseStream { + if s.writer != nil { + _, err = s.writer.Write([]byte("\n")) + } else { + _, err = s.handler.Write([]byte("\n")) + } + if err != nil { + return errors.WrapCaller(err) + } + } + + if s.writer != nil { + if flusher, ok := s.writer.(interface{ Flush() }); ok { + flusher.Flush() + } + } + + return nil } func (s *streamHTTP) RecvMsg(m any) error { @@ -115,11 +185,10 @@ func (s *streamHTTP) RecvMsg(m any) error { } method := s.handler.Method() + hasBody := method == http.MethodPut || method == http.MethodPost || method == http.MethodPatch + allowBody := hasBody || method == http.MethodDelete - if method == http.MethodPut || - method == http.MethodPost || - method == http.MethodDelete || - method == http.MethodPatch { + if allowBody { cur := args.ProtoReflect() for _, fd := range getReqBodyDesc(s.path) { cur = cur.Mutable(fd).Message() @@ -132,25 +201,56 @@ func (s *streamHTTP) RecvMsg(m any) error { return errors.Wrapf(reqInterceptor(s.handler, msg), "failed to go req interceptor request data by %s", reqName) } - if method == http.MethodPut || - method == http.MethodPost || - method == http.MethodPatch { - if len(s.handler.Body()) == 0 { - return errors.WrapCaller(fmt.Errorf("request body is nil, operation=%s", reqName)) - } + ct := string(s.handler.Request().Header.ContentType()) + isGRPC := isGRPCContentType(ct) + + // PUT/POST/PATCH 必须有 body (gRPC 请求除外,因为需要先解析帧) + if hasBody && !isGRPC && len(s.handler.Body()) == 0 { + return errors.WrapCaller(fmt.Errorf("request body is nil, operation=%s", reqName)) } if s.handler.Request().IsBodyStream() { - var b json.RawMessage - if err := json.NewDecoder(s.handler.Request().BodyStream()).Decode(&b); err != nil { - return errors.WrapCaller(err) - } + reader := s.handler.Request().BodyStream() + if isGRPC { + // Read gRPC frame header: 1 byte flags + 4 bytes length + header := make([]byte, 5) + if _, err := io.ReadFull(reader, header); err != nil { + return errors.WrapCaller(err) + } + length := binary.BigEndian.Uint32(header[1:5]) + data := make([]byte, length) + if _, err := io.ReadFull(reader, data); err != nil { + return errors.WrapCaller(err) + } + if err := proto.Unmarshal(data, msg); err != nil { + return errors.Wrapf(err, "failed to unmarshal body by protobuf, msg=%#v", msg) + } + } else { + var b json.RawMessage + if err := json.NewDecoder(reader).Decode(&b); err != nil { + return errors.WrapCaller(err) + } - if err := protojson.Default.Unmarshal(b, msg); err != nil { - return errors.Wrapf(err, "failed to unmarshal body by proto-json, msg=%#v", msg) + if err := protojson.Default.Unmarshal(b, msg); err != nil { + return errors.Wrapf(err, "failed to unmarshal body by proto-json, msg=%#v", msg) + } } } else { - if body := s.handler.Body(); len(body) > 0 { + body := s.handler.Body() + if isGRPC { + // gRPC frame: 1 byte flags + 4 bytes length + message + if len(body) < 5 { + return errors.New("invalid gRPC frame: too short") + } + length := binary.BigEndian.Uint32(body[1:5]) + if len(body) < int(5+length) { + return errors.Errorf("invalid gRPC frame: expected %d bytes, got %d", 5+length, len(body)) + } + data := body[5 : 5+length] + if err := proto.Unmarshal(data, msg); err != nil { + return errors.Wrapf(err, "failed to unmarshal body by protobuf, msg=%#v", msg) + } + } else if len(body) > 0 { if err := protojson.Default.Unmarshal(body, msg); err != nil { return errors.Wrapf(err, "failed to unmarshal body by proto-json, msg=%#v", msg) } diff --git a/pkg/gateway/stream.proxy.go b/pkg/gateway/stream.proxy.go index 74cbd844c..c513ed25b 100644 --- a/pkg/gateway/stream.proxy.go +++ b/pkg/gateway/stream.proxy.go @@ -103,8 +103,10 @@ func forwardClientToServer(out protoreflect.MessageType, src grpc.ClientStream, break } if err := dst.SendHeader(md); err != nil { - ret <- err - break + if !isDuplicateHeaderError(err) { + ret <- err + break + } } } if err := dst.SendMsg(f); err != nil { diff --git a/pkg/gateway/stream_http_test.go b/pkg/gateway/stream_http_test.go new file mode 100644 index 000000000..5af0657ca --- /dev/null +++ b/pkg/gateway/stream_http_test.go @@ -0,0 +1,63 @@ +package gateway + +import ( + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/valyala/fasthttp" + "google.golang.org/grpc/metadata" +) + +func TestIsGRPCContentType_GrpcWebJSONAlias(t *testing.T) { + if got := isGRPCContentType("application/grpc-web-json"); got { + t.Fatal("grpc-web-json alias should be treated as JSON transport") + } + + if got := isGRPCContentType("application/grpc-web-json; charset=utf-8"); got { + t.Fatal("grpc-web-json alias with charset should be treated as JSON transport") + } + + if got := isGRPCContentType("application/grpc+proto"); !got { + t.Fatal("application/grpc+proto should be treated as gRPC transport") + } +} + +func TestStreamHTTP_SendHeader_Idempotent(t *testing.T) { + app := fiber.New() + fctx := &fasthttp.RequestCtx{} + ctx := app.AcquireCtx(fctx) + defer app.ReleaseCtx(ctx) + + s := &streamHTTP{handler: ctx} + + if err := s.SendHeader(metadata.Pairs("x-test", "v1")); err != nil { + t.Fatalf("first SendHeader returned error: %v", err) + } + if err := s.SendHeader(metadata.Pairs("x-test", "v2")); err != nil { + t.Fatalf("second SendHeader should be idempotent, got error: %v", err) + } + + if got := string(ctx.Response().Header.Peek("x-test")); got != "v2" { + t.Fatalf("header value mismatch: got=%q want=%q", got, "v2") + } +} + +func TestStreamHTTP_SetHeader_AfterSendHeader_NoError(t *testing.T) { + app := fiber.New() + fctx := &fasthttp.RequestCtx{} + ctx := app.AcquireCtx(fctx) + defer app.ReleaseCtx(ctx) + + s := &streamHTTP{handler: ctx} + + if err := s.SendHeader(metadata.Pairs("x-test", "v1")); err != nil { + t.Fatalf("SendHeader returned error: %v", err) + } + if err := s.SetHeader(metadata.Pairs("x-extra", "ok")); err != nil { + t.Fatalf("SetHeader after SendHeader should not fail, got error: %v", err) + } + + if got := string(ctx.Response().Header.Peek("x-extra")); got != "ok" { + t.Fatalf("header value mismatch: got=%q want=%q", got, "ok") + } +} diff --git a/pkg/gateway/stream_proxy_test.go b/pkg/gateway/stream_proxy_test.go new file mode 100644 index 000000000..bc921b65a --- /dev/null +++ b/pkg/gateway/stream_proxy_test.go @@ -0,0 +1,95 @@ +package gateway + +import ( + "context" + "errors" + "io" + "testing" + + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/emptypb" +) + +type proxyFakeClientStream struct { + header metadata.MD + msgs []proto.Message + idx int +} + +func (p *proxyFakeClientStream) Header() (metadata.MD, error) { return p.header, nil } +func (p *proxyFakeClientStream) Trailer() metadata.MD { return nil } +func (p *proxyFakeClientStream) CloseSend() error { return nil } +func (p *proxyFakeClientStream) Context() context.Context { return context.Background() } +func (p *proxyFakeClientStream) SendMsg(any) error { return nil } +func (p *proxyFakeClientStream) RecvMsg(m any) error { + if p.idx >= len(p.msgs) { + return io.EOF + } + pm, ok := m.(proto.Message) + if !ok { + return io.ErrUnexpectedEOF + } + b, err := proto.Marshal(p.msgs[p.idx]) + if err != nil { + return err + } + p.idx++ + return proto.Unmarshal(b, pm) +} + +type proxyFakeServerStream struct { + sentMessages int +} + +func (p *proxyFakeServerStream) SetHeader(metadata.MD) error { return nil } +func (p *proxyFakeServerStream) SendHeader(metadata.MD) error { + return errors.New("headers already sent") +} +func (p *proxyFakeServerStream) SetTrailer(metadata.MD) {} +func (p *proxyFakeServerStream) Context() context.Context { return context.Background() } +func (p *proxyFakeServerStream) SendMsg(any) error { p.sentMessages++; return nil } +func (p *proxyFakeServerStream) RecvMsg(any) error { return io.EOF } + +func TestForwardClientToServer_IgnoresDuplicateHeaderError(t *testing.T) { + outType, err := protoregistry.GlobalTypes.FindMessageByName("google.protobuf.Empty") + if err != nil { + t.Fatalf("find output type: %v", err) + } + + src := &proxyFakeClientStream{ + header: metadata.Pairs("x-test", "1"), + msgs: []proto.Message{&emptypb.Empty{}}, + } + dst := &proxyFakeServerStream{} + + errCh := forwardClientToServer(outType, src, dst) + if got := <-errCh; got != io.EOF { + t.Fatalf("expected io.EOF, got %v", got) + } + if dst.sentMessages != 1 { + t.Fatalf("expected 1 forwarded message, got %d", dst.sentMessages) + } +} + +func TestIsDuplicateHeaderError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + {name: "headers already sent", err: errors.New("headers already sent"), want: true}, + {name: "SendHeader called multiple times", err: errors.New("SendHeader called multiple times"), want: true}, + {name: "other", err: errors.New("connection reset by peer"), want: false}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := isDuplicateHeaderError(tt.err); got != tt.want { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/gateway/util.go b/pkg/gateway/util.go index 9a210031f..be40ef39a 100644 --- a/pkg/gateway/util.go +++ b/pkg/gateway/util.go @@ -21,11 +21,23 @@ import ( ) func getReqBodyDesc(path *routertree.MatchOperation) []protoreflect.FieldDescriptor { - return path.Extras["req_body_desc"].([]protoreflect.FieldDescriptor) + if path == nil || path.Extras == nil { + return nil + } + if desc, ok := path.Extras["req_body_desc"].([]protoreflect.FieldDescriptor); ok { + return desc + } + return nil } func getRspBodyDesc(path *routertree.MatchOperation) []protoreflect.FieldDescriptor { - return path.Extras["rsp_body_desc"].([]protoreflect.FieldDescriptor) + if path == nil || path.Extras == nil { + return nil + } + if desc, ok := path.Extras["rsp_body_desc"].([]protoreflect.FieldDescriptor); ok { + return desc + } + return nil } func resolveBodyDesc(methodDesc protoreflect.MethodDescriptor, reqBody, rspBody string) map[string]any { @@ -137,12 +149,10 @@ func encodeBinHeader(b []byte) string { return base64.RawStdEncoding.EncodeToString(b) } -func decodeBinHeader(v string) (s string, err error) { - var b []byte - if len(v)%4 == 0 { - // Input was padded, or padding was not necessary. - b, err = base64.RawStdEncoding.DecodeString(v) - } else { +func decodeBinHeader(v string) (string, error) { + // 尝试使用标准 base64(带填充)解码,如果失败则使用 RawStdEncoding(无填充) + b, err := base64.StdEncoding.DecodeString(v) + if err != nil { b, err = base64.RawStdEncoding.DecodeString(v) } return string(b), err diff --git a/pkg/gateway/wrapper.go b/pkg/gateway/wrapper.go index c2e6ee7bf..0665207b8 100644 --- a/pkg/gateway/wrapper.go +++ b/pkg/gateway/wrapper.go @@ -44,33 +44,6 @@ type methodWrapper struct { meta *lavapbv1.RpcMeta } -//func (h methodWrapper) Handle(stream grpc.ServerStream) error { -// if h.grpcMethodDesc != nil { -// ctx := stream.Context() -// -// reply, err := h.grpcMethodDesc.Exec(h.srv.srv, ctx, stream.RecvMsg, h.srv.opts.unaryInterceptor) -// if err != nil { -// return errors.WrapCaller(err) -// } -// -// return errors.WrapCaller(stream.SendMsg(reply)) -// } else if h.grpcStreamDesc != nil { -// info := &grpc.StreamServerInfo{ -// FullMethod: h.grpcFullMethod, -// IsClientStream: h.grpcStreamDesc.ClientStreams, -// IsServerStream: h.grpcStreamDesc.ServerStreams, -// } -// -// if h.srv.opts.streamInterceptor != nil { -// return errors.WrapCaller(h.srv.opts.streamInterceptor(h.srv.srv, stream, info, h.grpcStreamDesc.Exec)) -// } else { -// return errors.WrapCaller(h.grpcStreamDesc.Exec(h.srv.srv, stream)) -// } -// } else { -// return errors.Format("cannot find server handler") -// } -//} - func grpcMethodHandlerWrapper(mth *methodWrapper, opts ...grpc.CallOption) MethodHandler { return func(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { in := mth.inputType.New().Interface() diff --git a/pkg/grpcutil/util.go b/pkg/grpcutil/util.go index 2970ebcc7..d9a19502a 100644 --- a/pkg/grpcutil/util.go +++ b/pkg/grpcutil/util.go @@ -73,7 +73,7 @@ func IsGRPCRequest(r *http.Request) bool { // // This makes it easy to register all the relevant routes in your HTTP router of choice. func ListGRPCResources(server *grpc.Server) []string { - var ret []string + ret := make([]string, 0, len(server.GetServiceInfo())) for serviceName, serviceInfo := range server.GetServiceInfo() { for _, methodInfo := range serviceInfo.Methods { ret = append(ret, fmt.Sprintf("/%s/%s", serviceName, methodInfo.Name)) diff --git a/pkg/httputil/fiber.go b/pkg/httputil/fiber.go index 0b31939fe..3a8fe995b 100644 --- a/pkg/httputil/fiber.go +++ b/pkg/httputil/fiber.go @@ -1,20 +1,19 @@ package httputil import ( - "bufio" "io" "net" "net/http" "strings" - fiber "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/utils" + fiber "github.com/gofiber/fiber/v3" + "github.com/gofiber/utils/v2" "github.com/valyala/fasthttp" "github.com/valyala/fasthttp/fasthttpadaptor" ) func StripPrefix(prefix string, hh fiber.Handler) fiber.Handler { - return func(ctx *fiber.Ctx) error { + return func(ctx fiber.Ctx) error { ctx.Request().Header.Set("Path-Prefix", prefix) ctx.Request().SetRequestURI(strings.TrimPrefix(string(ctx.Request().RequestURI()), prefix)) return hh(ctx) @@ -28,132 +27,13 @@ func FastHandler(h fasthttp.RequestHandler) http.Handler { func HTTPHandlerFunc(h http.HandlerFunc) fiber.Handler { return HTTPHandler(h) } func HTTPHandler(h http.Handler) fiber.Handler { - return func(c *fiber.Ctx) error { - handler := NewFastHTTPHandler(h) - handler(c.Context()) + return func(c fiber.Ctx) error { + handler := fasthttpadaptor.NewFastHTTPHandler(h) + handler(c.RequestCtx()) return nil } } -// NewFastHTTPHandlerFunc wraps net/http handler func to fasthttp -// request handler, so it can be passed to fasthttp server. -// -// While this function may be used for easy switching from net/http to fasthttp, -// it has the following drawbacks comparing to using manually written fasthttp -// request handler: -// -// - A lot of useful functionality provided by fasthttp is missing -// from net/http handler. -// - net/http -> fasthttp handler conversion has some overhead, -// so the returned handler will be always slower than manually written -// fasthttp handler. -// -// So it is advisable using this function only for quick net/http -> fasthttp -// switching. Then manually convert net/http handlers to fasthttp handlers -// according to https://github.com/valyala/fasthttp#switching-from-nethttp-to-fasthttp . -func NewFastHTTPHandlerFunc(h http.HandlerFunc) fasthttp.RequestHandler { - return NewFastHTTPHandler(h) -} - -// NewFastHTTPHandler wraps net/http handler to fasthttp request handler, -// so it can be passed to fasthttp server. -// -// While this function may be used for easy switching from net/http to fasthttp, -// it has the following drawbacks comparing to using manually written fasthttp -// request handler: -// -// - A lot of useful functionality provided by fasthttp is missing -// from net/http handler. -// - net/http -> fasthttp handler conversion has some overhead, -// so the returned handler will be always slower than manually written -// fasthttp handler. -// -// So it is advisable using this function only for quick net/http -> fasthttp -// switching. Then manually convert net/http handlers to fasthttp handlers -// according to https://github.com/valyala/fasthttp#switching-from-nethttp-to-fasthttp . -func NewFastHTTPHandler(h http.Handler) fasthttp.RequestHandler { - return func(ctx *fasthttp.RequestCtx) { - var r http.Request - if err := fasthttpadaptor.ConvertRequest(ctx, &r, true); err != nil { - ctx.Logger().Printf("cannot parse requestURI %q: %v", r.RequestURI, err) - ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError) - return - } - - w := netHTTPResponseWriter{ - h: r.Header, - w: ctx.Response.BodyWriter(), - r: ctx.RequestBodyStream(), - conn: ctx.Conn(), - } - h.ServeHTTP(&w, r.WithContext(ctx)) - - ctx.SetStatusCode(w.StatusCode()) - haveContentType := false - for k, vv := range w.Header() { - if k == fasthttp.HeaderContentType { - haveContentType = true - } - - for _, v := range vv { - ctx.Response.Header.Add(k, v) - } - } - if !haveContentType { - // From net/http.ResponseWriter.Write: - // If the Header does not contain a Content-Type line, Write adds a Content-Type set - // to the result of passing the initial 512 bytes of written data to DetectContentType. - l := 512 - b := ctx.Response.Body() - if len(b) < 512 { - l = len(b) - } - ctx.Response.Header.Set(fasthttp.HeaderContentType, http.DetectContentType(b[:l])) - } - } -} - -type netHTTPResponseWriter struct { - statusCode int - h http.Header - w io.Writer - r io.Reader - conn net.Conn -} - -func (w *netHTTPResponseWriter) StatusCode() int { - if w.statusCode == 0 { - return http.StatusOK - } - return w.statusCode -} - -func (w *netHTTPResponseWriter) Header() http.Header { - if w.h == nil { - w.h = make(http.Header) - } - return w.h -} - -func (w *netHTTPResponseWriter) WriteHeader(statusCode int) { - w.statusCode = statusCode -} - -func (w *netHTTPResponseWriter) Write(p []byte) (int, error) { - return w.w.Write(p) -} - -func (w *netHTTPResponseWriter) Flush() { - ff, ok := w.w.(http.Flusher) - if ok { - ff.Flush() - } -} - -func (w *netHTTPResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - return w.conn, &bufio.ReadWriter{Reader: bufio.NewReader(w.r), Writer: bufio.NewWriter(w.w)}, nil -} - func handlerFunc(h fasthttp.RequestHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // New fasthttp request diff --git a/pkg/httputil/header.go b/pkg/httputil/header.go index 58b86896f..ebd9d2a2d 100644 --- a/pkg/httputil/header.go +++ b/pkg/httputil/header.go @@ -1,6 +1,6 @@ package httputil -import "github.com/gofiber/fiber/v2" +import "github.com/gofiber/fiber/v3" // HTTP Headers were copied from net/http. const ( diff --git a/pkg/httputil/util.go b/pkg/httputil/util.go index cf31bd52d..f10c5e9ff 100644 --- a/pkg/httputil/util.go +++ b/pkg/httputil/util.go @@ -5,17 +5,17 @@ import ( "strings" "dario.cat/mergo" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/cors" "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/errors/errcode" "github.com/pubgo/funk/v2/proto/errorpb" - "github.com/pubgo/funk/v2/running" "github.com/samber/lo" "github.com/valyala/fasthttp" "google.golang.org/grpc/codes" "github.com/pubgo/lava/v2/core/encoding/protojson" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/pkg/fiberbuilder" ) @@ -62,7 +62,7 @@ func IsWebsocket(h *fasthttp.RequestHeader) bool { return false } -func ErrHandler(ctx *fiber.Ctx, err error) error { +func ErrHandler(ctx fiber.Ctx, err error) error { if err == nil { return nil } @@ -104,7 +104,7 @@ func Cors() fiber.Handler { AllowOriginsFunc: func(origin string) bool { return true }, - AllowMethods: strings.Join([]string{ + AllowMethods: []string{ fiber.MethodGet, fiber.MethodPost, fiber.MethodPut, @@ -112,7 +112,7 @@ func Cors() fiber.Handler { fiber.MethodPatch, fiber.MethodHead, fiber.MethodOptions, - }, ","), + }, // AllowHeaders: "", AllowCredentials: true, // ExposeHeaders: "", diff --git a/pkg/netutil/util.go b/pkg/netutil/util.go index 26881fbee..87374d930 100644 --- a/pkg/netutil/util.go +++ b/pkg/netutil/util.go @@ -190,3 +190,10 @@ func IsErrServerClosed(err error) bool { errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) } + +func SkipServerClosedError(err error) error { + if IsErrServerClosed(err) { + return nil + } + return err +} diff --git a/pkg/proto/lavapbv1/event.pb.go b/pkg/proto/lavapbv1/event.pb.go deleted file mode 100644 index 10b85773d..000000000 --- a/pkg/proto/lavapbv1/event.pb.go +++ /dev/null @@ -1,137 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.35.2 -// protoc v5.28.2 -// source: lava/event.proto - -package lavapbv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type EventType int32 - -const ( - EventType_UNKNOWN EventType = 0 - EventType_CREATE EventType = 1 - EventType_UPDATE EventType = 2 - EventType_DELETE EventType = 3 -) - -// Enum value maps for EventType. -var ( - EventType_name = map[int32]string{ - 0: "UNKNOWN", - 1: "CREATE", - 2: "UPDATE", - 3: "DELETE", - } - EventType_value = map[string]int32{ - "UNKNOWN": 0, - "CREATE": 1, - "UPDATE": 2, - "DELETE": 3, - } -) - -func (x EventType) Enum() *EventType { - p := new(EventType) - *p = x - return p -} - -func (x EventType) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (EventType) Descriptor() protoreflect.EnumDescriptor { - return file_lava_event_proto_enumTypes[0].Descriptor() -} - -func (EventType) Type() protoreflect.EnumType { - return &file_lava_event_proto_enumTypes[0] -} - -func (x EventType) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use EventType.Descriptor instead. -func (EventType) EnumDescriptor() ([]byte, []int) { - return file_lava_event_proto_rawDescGZIP(), []int{0} -} - -var File_lava_event_proto protoreflect.FileDescriptor - -var file_lava_event_proto_rawDesc = []byte{ - 0x0a, 0x10, 0x6c, 0x61, 0x76, 0x61, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x12, 0x07, 0x6c, 0x61, 0x76, 0x61, 0x2e, 0x76, 0x31, 0x2a, 0x3c, 0x0a, 0x09, 0x45, - 0x76, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, - 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, - 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x0a, 0x0a, - 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x03, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x75, 0x62, 0x67, 0x6f, 0x2f, 0x6c, 0x61, - 0x76, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6c, 0x61, 0x76, - 0x61, 0x70, 0x62, 0x76, 0x31, 0x3b, 0x6c, 0x61, 0x76, 0x61, 0x70, 0x62, 0x76, 0x31, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_lava_event_proto_rawDescOnce sync.Once - file_lava_event_proto_rawDescData = file_lava_event_proto_rawDesc -) - -func file_lava_event_proto_rawDescGZIP() []byte { - file_lava_event_proto_rawDescOnce.Do(func() { - file_lava_event_proto_rawDescData = protoimpl.X.CompressGZIP(file_lava_event_proto_rawDescData) - }) - return file_lava_event_proto_rawDescData -} - -var file_lava_event_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_lava_event_proto_goTypes = []any{ - (EventType)(0), // 0: lava.v1.EventType -} -var file_lava_event_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_lava_event_proto_init() } -func file_lava_event_proto_init() { - if File_lava_event_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_lava_event_proto_rawDesc, - NumEnums: 1, - NumMessages: 0, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_lava_event_proto_goTypes, - DependencyIndexes: file_lava_event_proto_depIdxs, - EnumInfos: file_lava_event_proto_enumTypes, - }.Build() - File_lava_event_proto = out.File - file_lava_event_proto_rawDesc = nil - file_lava_event_proto_goTypes = nil - file_lava_event_proto_depIdxs = nil -} diff --git a/pkg/wsbuilder/websocket.go b/pkg/wsbuilder/websocket.go index 12db4020b..d67b2c978 100644 --- a/pkg/wsbuilder/websocket.go +++ b/pkg/wsbuilder/websocket.go @@ -5,8 +5,8 @@ import ( "time" "github.com/fasthttp/websocket" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/utils" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/utils/v2" "github.com/valyala/fasthttp" ) @@ -14,7 +14,7 @@ import ( type WsCfg struct { // Filter defines a function to skip middleware. // Optional. Default: nil - Filter func(*fiber.Ctx) bool + Filter func(fiber.Ctx) bool // HandshakeTimeout specifies the duration for the handshake to complete. HandshakeTimeout time.Duration @@ -40,7 +40,7 @@ type WsCfg struct { // NewWs returns a new `handler func(*Conn)` that upgrades a client to the // websocket protocol, you can pass an optional config. -func NewWs(handler func(*fiber.Ctx, *Conn), config ...WsCfg) fiber.Handler { +func NewWs(handler func(fiber.Ctx, *Conn), config ...WsCfg) fiber.Handler { // Init config var cfg WsCfg if len(config) > 0 { @@ -74,10 +74,10 @@ func NewWs(handler func(*fiber.Ctx, *Conn), config ...WsCfg) fiber.Handler { return false }, } - return func(c *fiber.Ctx) error { + return func(c fiber.Ctx) error { conn := acquireConn() // locals - c.Context().VisitUserValues(func(key []byte, value any) { + c.RequestCtx().VisitUserValues(func(key []byte, value any) { conn.locals[string(key)] = value }) @@ -88,16 +88,16 @@ func NewWs(handler func(*fiber.Ctx, *Conn), config ...WsCfg) fiber.Handler { } // queries - for key, value := range c.Context().QueryArgs().All() { + for key, value := range c.RequestCtx().QueryArgs().All() { conn.queries[string(key)] = string(value) } // cookies - for key, value := range c.Context().Request.Header.All() { + for key, value := range c.Request().Header.All() { conn.cookies[string(key)] = string(value) } - if err := upgrader.Upgrade(c.Context(), func(fconn *websocket.Conn) { + if err := upgrader.Upgrade(c.RequestCtx(), func(fconn *websocket.Conn) { conn.Conn = fconn defer releaseConn(conn) handler(c, conn) diff --git a/pkg/wsbuilder/ws.go b/pkg/wsbuilder/ws.go index a608005e0..6e4c2996a 100644 --- a/pkg/wsbuilder/ws.go +++ b/pkg/wsbuilder/ws.go @@ -6,8 +6,8 @@ import ( "time" "github.com/fasthttp/websocket" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/utils" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/utils/v2" "github.com/valyala/fasthttp" ) @@ -15,7 +15,7 @@ import ( type Config struct { // Filter defines a function to skip middleware. // Optional. Default: nil - Filter func(*fiber.Ctx) bool + Filter func(fiber.Ctx) bool // HandshakeTimeout specifies the duration for the handshake to complete. HandshakeTimeout time.Duration @@ -52,7 +52,7 @@ type Config struct { // New returns a new `handler func(*Conn)` that upgrades a client to the // websocket protocol, you can pass an optional config. -func New(ctx *fiber.Ctx, call func(c *websocket.Conn), config ...Config) (err error) { +func New(ctx fiber.Ctx, call func(c *websocket.Conn), config ...Config) (err error) { // Init config var cfg Config if len(config) > 0 { @@ -92,7 +92,7 @@ func New(ctx *fiber.Ctx, call func(c *websocket.Conn), config ...Config) (err er }, } - return wsUp.Upgrade(ctx.Context(), call) + return wsUp.Upgrade(ctx.RequestCtx(), call) } // Close codes defined in RFC 6455, section 11.7. @@ -168,8 +168,8 @@ func IsUnexpectedCloseError(err error, expectedCodes ...int) bool { // IsWebSocketUpgrade returns true if the client requested upgrade to the // WebSocket protocol. -func IsWebSocketUpgrade(c *fiber.Ctx) bool { - return websocket.FastHTTPIsWebSocketUpgrade(c.Context()) +func IsWebSocketUpgrade(c fiber.Ctx) bool { + return websocket.FastHTTPIsWebSocketUpgrade(c.RequestCtx()) } // JoinMessages concatenates received messages to create a single io.Reader. diff --git a/pkg/wsproxy/websocket_proxy.go b/pkg/wsproxy/websocket_proxy.go index 42be1ccdf..305460e7c 100644 --- a/pkg/wsproxy/websocket_proxy.go +++ b/pkg/wsproxy/websocket_proxy.go @@ -3,6 +3,7 @@ package wsproxy import ( "bufio" "bytes" + "context" "errors" "io" "net" @@ -14,7 +15,6 @@ import ( "github.com/gorilla/websocket" "github.com/pubgo/funk/v2/closer" "github.com/pubgo/funk/v2/log" - "golang.org/x/net/context" "github.com/pubgo/lava/v2/internal/logutil" ) diff --git a/protobuf.yaml b/protobuf.yaml index b1e125733..a0c95b8b1 100644 --- a/protobuf.yaml +++ b/protobuf.yaml @@ -5,27 +5,30 @@ base: paths: import module: github.com/pubgo/lava/v2/pkg root: - - proto + - proto includes: - - proto + - proto deps: - - name: google - url: github.com/googleapis/googleapis - path: /google - version: v0.0.0-20220224004616-3c171936039b - - name: google/protobuf - url: /usr/local/include/google/protobuf - optional: true - - name: google/protobuf - url: /opt/homebrew/include/google/protobuf - optional: true - - name: errorpb - url: github.com/pubgo/funk/v2 - path: /proto/errorpb - version: v2.0.0-beta.8 + - name: google + url: github.com/googleapis/googleapis + path: /google + version: v0.0.0-20220224004616-3c171936039b + - name: google/protobuf + url: /usr/local/include/google/protobuf + optional: true + - name: google/protobuf + url: /opt/homebrew/include/google/protobuf + optional: true + - name: errorpb + url: github.com/pubgo/funk/v2 + path: /proto/errorpb + version: v2.0.0-beta.10 plugins: - - name: go - - name: go-errors2 - - name: go-grpc - opt: - - require_unimplemented_servers=false + - name: go + - name: go-errors2 + - name: go-grpc + opt: + - require_unimplemented_servers=false +linter: + rules: {} + format_type: github_actions diff --git a/servers/grpcs/middleware.go b/servers/grpcs/middleware.go index 54ef9c60b..80f4e9710 100644 --- a/servers/grpcs/middleware.go +++ b/servers/grpcs/middleware.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" grpcMiddle "github.com/grpc-ecosystem/go-grpc-middleware" "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/convert" @@ -247,20 +247,29 @@ func handlerStreamMiddle(middlewares map[string][]lava.Middleware) grpc.StreamSe for key, value := range h.All() { md.Append(convert.BtoS(key), convert.BtoS(value)) } - return grpc.SendHeader(ctx, md) + if len(md) == 0 { + return nil + } + + if err = grpc.SetTrailer(ctx, md); err != nil { + log.Err(err, ctx). + Str("grpc-method", info.FullMethod). + Msg("grpc set stream trailer failed") + } + + return nil } } -func handlerHttpMiddle(middlewares []lava.Middleware) func(fbCtx *fiber.Ctx) error { +func handlerHttpMiddle(middlewares []lava.Middleware) func(fbCtx fiber.Ctx) error { h := func(ctx context.Context, req lava.Request) (lava.Response, error) { reqCtx := req.(*httpRequest).ctx - reqCtx.SetUserContext(ctx) return &httpResponse{ctx: reqCtx}, reqCtx.Next() } h = lava.Chain(middlewares...).Middleware(h) - return func(ctx *fiber.Ctx) error { - _, err := h(ctx.Context(), &httpRequest{ctx: ctx}) + return func(ctx fiber.Ctx) error { + _, err := h(ctx, &httpRequest{ctx: ctx}) return err } } diff --git a/servers/grpcs/request.go b/servers/grpcs/request.go index b7e0d0667..75e140620 100644 --- a/servers/grpcs/request.go +++ b/servers/grpcs/request.go @@ -3,7 +3,7 @@ package grpcs import ( "fmt" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "google.golang.org/grpc" "github.com/pubgo/lava/v2/lava" @@ -38,7 +38,7 @@ func (r *rpcRequest) Stream() bool { return r.stream != nil } var _ lava.Request = (*httpRequest)(nil) type httpRequest struct { - ctx *fiber.Ctx + ctx fiber.Ctx } func (r *httpRequest) Kind() string { return lava.RequestKindHttp } diff --git a/servers/grpcs/response.go b/servers/grpcs/response.go index 17bae82db..0fcfeb8ed 100644 --- a/servers/grpcs/response.go +++ b/servers/grpcs/response.go @@ -1,7 +1,7 @@ package grpcs import ( - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "google.golang.org/grpc" "github.com/pubgo/lava/v2/lava" @@ -22,7 +22,7 @@ func (h *rpcResponse) Stream() bool { return h.stream != nil } var _ lava.Response = (*httpResponse)(nil) type httpResponse struct { - ctx *fiber.Ctx + ctx fiber.Ctx } func (h *httpResponse) Header() *lava.ResponseHeader { return &h.ctx.Response().Header } diff --git a/servers/grpcs/server.go b/servers/grpcs/server.go index 7d2e520a9..b2c54df1c 100644 --- a/servers/grpcs/server.go +++ b/servers/grpcs/server.go @@ -6,20 +6,20 @@ import ( "net" "strings" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/async" "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/config" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/funk/v2/vars" "github.com/samber/lo" "google.golang.org/grpc" "github.com/pubgo/lava/v2/core/debug" "github.com/pubgo/lava/v2/core/metrics" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/core/supervisor" "github.com/pubgo/lava/v2/internal/logutil" "github.com/pubgo/lava/v2/internal/middlewares/middleware_accesslog" @@ -108,15 +108,22 @@ func (s *serviceImpl) init( middleware_serviceinfo.New(), middleware_metric.New(metric), middleware_accesslog.New(log), - middleware_recovery.New(), } globalMiddlewares = append(globalMiddlewares, dixMiddlewares...) + globalMiddlewares = append(globalMiddlewares, middleware_recovery.New()) log = log.WithName("grpc-server") - s.log = log httpServer := fiber.New(conf.Http.Build().Unwrap()) httpServer.Use(httputil.Cors()) + httpServer.Use(func(ctx fiber.Ctx) error { + log.Debug(). + Str("path", ctx.Path()). + Str("method", ctx.Method()). + Str("header", ctx.Request().Header.String()). + Msg("grpc gateway router") + return ctx.Next() + }) for _, h := range grpcRouters { r, ok := h.(lava.HttpRouter) @@ -206,23 +213,28 @@ func (s *serviceImpl) init( // grpcServer.RegisterService(h.ServiceDesc(), h) //} - grpcGatewayApiPrefix := "api" - s.log.Info().Msgf("service gateway base path: %s", grpcGatewayApiPrefix) - - for _, m := range mux.GetRouteMethods() { - log.Info(). - Str("operation", m.Operation). - Any("rpc-meta", lo.FromPtr(mux.GetOperation(m.Operation)).Meta). - Str("verb", m.Verb). - Any("path-vars", m.Vars). - Str("extras", fmt.Sprintf("%v", m.Extras)). - Msgf("grpc gateway router info: %s %s", m.Method, "/"+strings.Trim(grpcGatewayApiPrefix, "/")+m.Path) + grpcGatewayApiPrefix := "/api" + log.Info().Msgf("service gateway base path: %s", grpcGatewayApiPrefix) + + if conf.EnablePrintRouter { + for _, m := range mux.GetRouteMethods() { + log.Info(). + Str("operation", m.Operation). + Any("rpc-meta", lo.FromPtr(mux.GetOperation(m.Operation)).Meta). + Str("verb", m.Verb). + Any("path-vars", m.Vars). + Str("extras", fmt.Sprintf("%v", m.Extras)). + Msgf("grpc gateway router info: %s %s", m.Method, "/"+strings.Trim(grpcGatewayApiPrefix, "/")+m.Path) + } } - httpServer.Mount("/debug", debug.App()) - httpServer.Mount("/", httpApp) - httpServer.Group(grpcGatewayApiPrefix, httputil.StripPrefix(grpcGatewayApiPrefix, mux.Handler)) + httpServer.Use("/debug", debug.App()) + httpServer.Use("/", httpApp) + httpServer.Use(grpcGatewayApiPrefix, func(ctx fiber.Ctx) error { + return httputil.StripPrefix(grpcGatewayApiPrefix, mux.Handler)(ctx) + }) + s.log = log s.httpServer = httpServer s.grpcServer = grpcServer diff --git a/servers/https/httphandler/router.go b/servers/https/httphandler/router.go index 2effbd329..7b883cb6b 100644 --- a/servers/https/httphandler/router.go +++ b/servers/https/httphandler/router.go @@ -5,32 +5,33 @@ import ( "net/http" "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" ) -type Handler[Req any, Rsp any] func(ctx *fiber.Ctx, req *Req) (rsp *Rsp, err error) +type Handler[Req any, Rsp any] func(ctx fiber.Ctx, req *Req) (rsp *Rsp, err error) var validate = validator.New() -func WrapHandler[Req, Rsp any](handler Handler[Req, Rsp]) func(ctx *fiber.Ctx) error { - return func(ctx *fiber.Ctx) error { +func WrapHandler[Req, Rsp any](handler Handler[Req, Rsp]) func(ctx fiber.Ctx) error { + return func(ctx fiber.Ctx) error { var req Req - if err := ctx.ParamsParser(&req); err != nil { - return fmt.Errorf("failed to parse params, params:%v err:%w", ctx.AllParams(), err) + bind := ctx.Bind() + if err := bind.URI(&req); err != nil { + return fmt.Errorf("failed to parse params, params:%v err:%w", ctx.Route().Params, err) } - if err := ctx.QueryParser(&req); err != nil { + if err := bind.Query(&req); err != nil { return fmt.Errorf("failed to parse query, query:%v err:%w", ctx.Queries(), err) } - if err := ctx.ReqHeaderParser(&req); err != nil { + if err := bind.Header(&req); err != nil { return fmt.Errorf("failed to parse header, header:%q err:%w", ctx.GetReqHeaders(), err) } switch ctx.Method() { case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: - if err := ctx.BodyParser(&req); err != nil { + if err := bind.Body(&req); err != nil { return fmt.Errorf("failed to parse body, err:%w", err) } } diff --git a/servers/https/middleware.go b/servers/https/middleware.go index 1e8b66b9d..6d983aabc 100644 --- a/servers/https/middleware.go +++ b/servers/https/middleware.go @@ -3,36 +3,36 @@ package https import ( "context" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/binder" "github.com/pubgo/lava/v2/lava" ) func init() { - fiber.SetParserDecoder(fiber.ParserConfig{ + binder.SetParserDecoder(binder.ParserConfig{ IgnoreUnknownKeys: true, ZeroEmpty: true, }) } -func RegParser(parsers []fiber.ParserType) { - fiber.SetParserDecoder(fiber.ParserConfig{ +func RegParser(parsers []binder.ParserType) { + binder.SetParserDecoder(binder.ParserConfig{ IgnoreUnknownKeys: true, ZeroEmpty: true, ParserType: parsers, }) } -func handlerHttpMiddle(middlewares []lava.Middleware) func(fbCtx *fiber.Ctx) error { +func handlerHttpMiddle(middlewares []lava.Middleware) func(fbCtx fiber.Ctx) error { h := func(ctx context.Context, req lava.Request) (lava.Response, error) { reqCtx := req.(*httpRequest).ctx - reqCtx.SetUserContext(ctx) return &httpResponse{ctx: reqCtx}, reqCtx.Next() } h = lava.Chain(middlewares...).Middleware(h) - return func(ctx *fiber.Ctx) error { - _, err := h(ctx.Context(), &httpRequest{ctx: ctx}) + return func(ctx fiber.Ctx) error { + _, err := h(ctx, &httpRequest{ctx: ctx}) return err } } diff --git a/servers/https/request.go b/servers/https/request.go index e1489db61..14a13949c 100644 --- a/servers/https/request.go +++ b/servers/https/request.go @@ -3,7 +3,7 @@ package https import ( "fmt" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/lava/v2/lava" ) @@ -11,7 +11,7 @@ import ( var _ lava.Request = (*httpRequest)(nil) type httpRequest struct { - ctx *fiber.Ctx + ctx fiber.Ctx } func (r *httpRequest) Kind() string { return lava.RequestKindHttp } diff --git a/servers/https/response.go b/servers/https/response.go index eac0a6ff3..9d879f931 100644 --- a/servers/https/response.go +++ b/servers/https/response.go @@ -1,7 +1,7 @@ package https import ( - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/lava/v2/lava" ) @@ -9,7 +9,7 @@ import ( var _ lava.Response = (*httpResponse)(nil) type httpResponse struct { - ctx *fiber.Ctx + ctx fiber.Ctx } func (h *httpResponse) Header() *lava.ResponseHeader { return &h.ctx.Response().Header } diff --git a/servers/https/server.go b/servers/https/server.go index 56175d7d3..ac5fc22d6 100644 --- a/servers/https/server.go +++ b/servers/https/server.go @@ -4,17 +4,17 @@ import ( "context" "fmt" - "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v3" "github.com/pubgo/funk/v2/async" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/running" "github.com/pubgo/funk/v2/vars" "github.com/samber/lo" "github.com/pubgo/lava/v2/core/debug" "github.com/pubgo/lava/v2/core/lifecycle" "github.com/pubgo/lava/v2/core/metrics" + "github.com/pubgo/lava/v2/core/running" "github.com/pubgo/lava/v2/core/supervisor" "github.com/pubgo/lava/v2/internal/logutil" "github.com/pubgo/lava/v2/internal/middlewares/middleware_accesslog" @@ -63,14 +63,15 @@ func (s *serviceImpl) init(params Params) { s.log = params.Log.WithName(s.String()) s.httpServer = fiber.New(cfg.Http.Build().Unwrap()) s.httpServer.Use(httputil.Cors()) - s.httpServer.Mount("/debug", debug.App()) + s.httpServer.Use("/debug", debug.App()) - defaultMiddlewares := []lava.Middleware{ + defaultMiddlewares := make([]lava.Middleware, 0, 4+len(params.Middlewares)) + defaultMiddlewares = append(defaultMiddlewares, middleware_serviceinfo.New(), middleware_metric.New(params.M), middleware_accesslog.New(s.log), middleware_recovery.New(), - } + ) middlewares := append(defaultMiddlewares, params.Middlewares...) for _, h := range params.Handlers { diff --git a/taskfile.yml b/taskfile.yml index 3ad168217..5dcba61f8 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -19,6 +19,7 @@ vars: includes: scheduler: ./internal/examples/scheduler + tunnel: ./internal/examples/tunnel tasks: default: diff --git a/tools/fileserver/main.go b/tools/fileserver/main.go deleted file mode 100644 index 378f39a20..000000000 --- a/tools/fileserver/main.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/pubgo/funk/v2/log" - "github.com/samber/lo" - "github.com/valyala/fasthttp" -) - -var port = flag.Int("port", 8080, "http port") - -func main() { - flag.Parse() - - wd := lo.Must1(os.Getwd()) - if len(os.Args) > 0 { - wd = flag.Arg(1) - } - - log.Info().Msgf("file dir: %s", wd) - log.Info().Msgf("http://localhost:%v", lo.FromPtr(port)) - - fs := &fasthttp.FS{ - Root: wd, - IndexNames: []string{"index.html"}, - GenerateIndexPages: true, - Compress: false, - AcceptByteRange: true, - // PathRewrite: fasthttp.NewVHostPathRewriter(0), - } - - s := &fasthttp.Server{ - Handler: fs.NewRequestHandler(), - Logger: log.NewStd(log.GetLogger("fileserver")), - } - - fmt.Println(s.ListenAndServe(fmt.Sprintf(":%v", lo.FromPtr(port)))) -}