litclaude-ai 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/README_ko-KR.md +374 -0
- package/RELEASE_CHECKLIST.md +165 -0
- package/bin/litclaude-ai.js +643 -0
- package/cover.png +0 -0
- package/docs/agents.md +67 -0
- package/docs/hooks.md +134 -0
- package/docs/lsp.md +40 -0
- package/docs/migration.md +209 -0
- package/docs/workflow-compatibility-audit.md +119 -0
- package/generate_cover.py +123 -0
- package/package.json +48 -0
- package/plugins/litclaude/.claude-plugin/plugin.json +25 -0
- package/plugins/litclaude/.lsp.json +13 -0
- package/plugins/litclaude/.mcp.json +9 -0
- package/plugins/litclaude/agents/boulder-executor.md +12 -0
- package/plugins/litclaude/agents/librarian-researcher.md +15 -0
- package/plugins/litclaude/agents/oracle-verifier.md +16 -0
- package/plugins/litclaude/agents/prometheus-planner.md +13 -0
- package/plugins/litclaude/agents/qa-runner.md +16 -0
- package/plugins/litclaude/agents/quality-reviewer.md +17 -0
- package/plugins/litclaude/bin/litclaude-hook.js +110 -0
- package/plugins/litclaude/bin/litclaude-hud.js +271 -0
- package/plugins/litclaude/bin/litclaude-lsp-doctor.js +15 -0
- package/plugins/litclaude/bin/litclaude-mcp.js +70 -0
- package/plugins/litclaude/commands/deep-interview.md +21 -0
- package/plugins/litclaude/commands/dynamic-workflow.md +36 -0
- package/plugins/litclaude/commands/lit-loop.md +40 -0
- package/plugins/litclaude/commands/lit-plan.md +35 -0
- package/plugins/litclaude/commands/litgoal.md +30 -0
- package/plugins/litclaude/commands/review-work.md +35 -0
- package/plugins/litclaude/commands/start-work.md +36 -0
- package/plugins/litclaude/hooks/hooks.json +54 -0
- package/plugins/litclaude/lib/context-pressure.mjs +25 -0
- package/plugins/litclaude/lib/hud-accent-palette.mjs +58 -0
- package/plugins/litclaude/lib/litgoal/cli.mjs +266 -0
- package/plugins/litclaude/lib/litgoal/ledger.mjs +16 -0
- package/plugins/litclaude/lib/litgoal/paths.mjs +7 -0
- package/plugins/litclaude/lib/litgoal/state.mjs +67 -0
- package/plugins/litclaude/lib/mutated-file-paths.mjs +63 -0
- package/plugins/litclaude/lib/start-work-continuation.mjs +99 -0
- package/plugins/litclaude/lib/workflow-check.mjs +83 -0
- package/plugins/litclaude/skills/ai-slop-remover/SKILL.md +142 -0
- package/plugins/litclaude/skills/comment-checker/SKILL.md +55 -0
- package/plugins/litclaude/skills/debugging/SKILL.md +70 -0
- package/plugins/litclaude/skills/debugging/references/methodology/00-setup.md +108 -0
- package/plugins/litclaude/skills/debugging/references/methodology/02-investigate.md +126 -0
- package/plugins/litclaude/skills/debugging/references/methodology/04-oracle-triple.md +106 -0
- package/plugins/litclaude/skills/debugging/references/methodology/05-escalate.md +69 -0
- package/plugins/litclaude/skills/debugging/references/methodology/06-fix.md +116 -0
- package/plugins/litclaude/skills/debugging/references/methodology/08-qa.md +94 -0
- package/plugins/litclaude/skills/debugging/references/methodology/09-cleanup.md +164 -0
- package/plugins/litclaude/skills/debugging/references/methodology/partial-runtime-evidence.md +228 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/go.md +252 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/native-binary.md +484 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/node.md +260 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/python.md +248 -0
- package/plugins/litclaude/skills/debugging/references/runtimes/rust.md +234 -0
- package/plugins/litclaude/skills/debugging/references/tools/ghidra.md +212 -0
- package/plugins/litclaude/skills/debugging/references/tools/playwright-cli.md +194 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwndbg.md +263 -0
- package/plugins/litclaude/skills/debugging/references/tools/pwntools.md +265 -0
- package/plugins/litclaude/skills/deep-interview/SKILL.md +323 -0
- package/plugins/litclaude/skills/deep-interview/scripts/render_progress.py +193 -0
- package/plugins/litclaude/skills/frontend-ui-ux/SKILL.md +62 -0
- package/plugins/litclaude/skills/lit-loop/SKILL.md +144 -0
- package/plugins/litclaude/skills/lit-plan/SKILL.md +125 -0
- package/plugins/litclaude/skills/litgoal/SKILL.md +219 -0
- package/plugins/litclaude/skills/lsp/SKILL.md +63 -0
- package/plugins/litclaude/skills/programming/SKILL.md +106 -0
- package/plugins/litclaude/skills/programming/references/go/README.md +90 -0
- package/plugins/litclaude/skills/programming/references/go/backend-stack.md +641 -0
- package/plugins/litclaude/skills/programming/references/go/bootstrap.md +328 -0
- package/plugins/litclaude/skills/programming/references/go/bubbletea-v2.md +360 -0
- package/plugins/litclaude/skills/programming/references/go/cobra-stack.md +468 -0
- package/plugins/litclaude/skills/programming/references/go/concurrency.md +362 -0
- package/plugins/litclaude/skills/programming/references/go/data-modeling.md +329 -0
- package/plugins/litclaude/skills/programming/references/go/error-handling.md +359 -0
- package/plugins/litclaude/skills/programming/references/go/golangci-strict.md +236 -0
- package/plugins/litclaude/skills/programming/references/go/grpc-connect.md +375 -0
- package/plugins/litclaude/skills/programming/references/go/libraries.md +337 -0
- package/plugins/litclaude/skills/programming/references/go/one-liners.md +202 -0
- package/plugins/litclaude/skills/programming/references/go/sqlc-pgx.md +471 -0
- package/plugins/litclaude/skills/programming/references/go/testing.md +467 -0
- package/plugins/litclaude/skills/programming/references/go/type-patterns.md +298 -0
- package/plugins/litclaude/skills/programming/references/python/README.md +314 -0
- package/plugins/litclaude/skills/programming/references/python/async-anyio.md +442 -0
- package/plugins/litclaude/skills/programming/references/python/data-modeling.md +233 -0
- package/plugins/litclaude/skills/programming/references/python/data-processing.md +133 -0
- package/plugins/litclaude/skills/programming/references/python/error-handling.md +218 -0
- package/plugins/litclaude/skills/programming/references/python/fastapi-stack.md +316 -0
- package/plugins/litclaude/skills/programming/references/python/httpx2-optimization.md +360 -0
- package/plugins/litclaude/skills/programming/references/python/libraries.md +307 -0
- package/plugins/litclaude/skills/programming/references/python/one-liners.md +268 -0
- package/plugins/litclaude/skills/programming/references/python/orjson-stack.md +378 -0
- package/plugins/litclaude/skills/programming/references/python/pydantic-ai.md +285 -0
- package/plugins/litclaude/skills/programming/references/python/pyproject-strict.md +232 -0
- package/plugins/litclaude/skills/programming/references/python/textual-tui.md +201 -0
- package/plugins/litclaude/skills/programming/references/python/type-patterns.md +176 -0
- package/plugins/litclaude/skills/programming/references/rust/README.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/async-tokio.md +299 -0
- package/plugins/litclaude/skills/programming/references/rust/axum-stack.md +467 -0
- package/plugins/litclaude/skills/programming/references/rust/cargo-strict.md +317 -0
- package/plugins/litclaude/skills/programming/references/rust/clap-stack.md +409 -0
- package/plugins/litclaude/skills/programming/references/rust/concurrency.md +375 -0
- package/plugins/litclaude/skills/programming/references/rust/libraries.md +439 -0
- package/plugins/litclaude/skills/programming/references/rust/one-liners.md +291 -0
- package/plugins/litclaude/skills/programming/references/rust/proptest-insta.md +429 -0
- package/plugins/litclaude/skills/programming/references/rust/type-state.md +354 -0
- package/plugins/litclaude/skills/programming/references/rust/unsafe-discipline.md +250 -0
- package/plugins/litclaude/skills/programming/references/rust/zero-cost-safety.md +527 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/README.md +289 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
- package/plugins/litclaude/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
- package/plugins/litclaude/skills/programming/references/typescript/README.md +195 -0
- package/plugins/litclaude/skills/programming/references/typescript/backend-hono.md +672 -0
- package/plugins/litclaude/skills/programming/references/typescript/bootstrap.md +199 -0
- package/plugins/litclaude/skills/programming/references/typescript/data-modeling.md +202 -0
- package/plugins/litclaude/skills/programming/references/typescript/error-handling.md +169 -0
- package/plugins/litclaude/skills/programming/references/typescript/tsconfig-strict.md +152 -0
- package/plugins/litclaude/skills/programming/references/typescript/type-patterns.md +196 -0
- package/plugins/litclaude/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
- package/plugins/litclaude/skills/programming/scripts/go/new-project.py +138 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.editorconfig +13 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/.golangci.yml +95 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/ci.yml +37 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/config.go +24 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/gitignore +15 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
- package/plugins/litclaude/skills/programming/scripts/go/templates/run.go +15 -0
- package/plugins/litclaude/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-project.py +172 -0
- package/plugins/litclaude/skills/programming/scripts/python/new-script.py +116 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
- package/plugins/litclaude/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
- package/plugins/litclaude/skills/programming/scripts/rust/new-project.py +175 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
- package/plugins/litclaude/skills/programming/scripts/typescript/new-project.ts +177 -0
- package/plugins/litclaude/skills/refactor/SKILL.md +73 -0
- package/plugins/litclaude/skills/remove-ai-slops/SKILL.md +52 -0
- package/plugins/litclaude/skills/review-work/SKILL.md +331 -0
- package/plugins/litclaude/skills/rules/SKILL.md +66 -0
- package/plugins/litclaude/skills/start-work/SKILL.md +132 -0
- package/scripts/audit-plan-checkboxes.mjs +37 -0
- package/scripts/doctor.mjs +41 -0
- package/scripts/inspect-agent-tools.mjs +27 -0
- package/scripts/postinstall.mjs +50 -0
- package/scripts/qa-claude-plugin-smoke.sh +60 -0
- package/scripts/qa-portable-install.sh +136 -0
- package/scripts/validate-plugin.mjs +72 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
# HTTP Backend Stack — gin + slog + validator + pgx
|
|
2
|
+
|
|
3
|
+
The canonical production HTTP service skeleton. Distilled from the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) codebase — a real proxy serving OpenAI / Gemini / Claude / Codex APIs in production, with SSE streaming, WebSocket upgrades, request logging, and hot-reload config.
|
|
4
|
+
|
|
5
|
+
If you are tempted to pick echo or chi instead, see `libraries.md` — gin wins on ecosystem, not technical merit, and the win is large enough to matter.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## `go.mod`
|
|
10
|
+
|
|
11
|
+
```go
|
|
12
|
+
module github.com/your-org/myservice
|
|
13
|
+
|
|
14
|
+
go 1.23
|
|
15
|
+
|
|
16
|
+
require (
|
|
17
|
+
github.com/gin-gonic/gin v1.10.1
|
|
18
|
+
github.com/go-playground/validator/v10 v10.22.1
|
|
19
|
+
github.com/caarlos0/env/v11 v11.2.2
|
|
20
|
+
github.com/google/uuid v1.6.0
|
|
21
|
+
github.com/jackc/pgx/v5 v5.7.6
|
|
22
|
+
golang.org/x/sync v0.18.0
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Project structure
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
cmd/server/main.go # ≤ 50 LOC; flags → run.Execute(ctx)
|
|
32
|
+
internal/
|
|
33
|
+
cmd/run.go # ~150 LOC; signal handling, config load, server.Run
|
|
34
|
+
config/config.go # env-driven Config struct
|
|
35
|
+
api/
|
|
36
|
+
server.go # gin.Engine setup, route mounting, http.Server
|
|
37
|
+
middleware/
|
|
38
|
+
request_id.go
|
|
39
|
+
request_logging.go
|
|
40
|
+
auth.go
|
|
41
|
+
recovery.go
|
|
42
|
+
cors.go
|
|
43
|
+
handlers/
|
|
44
|
+
users.go # one file per resource
|
|
45
|
+
streams.go # SSE / WebSocket endpoints
|
|
46
|
+
domain/ # smart-constructor types (Email, UserID, ...)
|
|
47
|
+
service/ # business logic
|
|
48
|
+
store/ # pgx + sqlc
|
|
49
|
+
obs/
|
|
50
|
+
logger.go # slog setup
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## `cmd/server/main.go`
|
|
56
|
+
|
|
57
|
+
```go
|
|
58
|
+
package main
|
|
59
|
+
|
|
60
|
+
import (
|
|
61
|
+
"context"
|
|
62
|
+
"log/slog"
|
|
63
|
+
"os"
|
|
64
|
+
"os/signal"
|
|
65
|
+
"syscall"
|
|
66
|
+
|
|
67
|
+
"github.com/your-org/myservice/internal/cmd"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
func main() {
|
|
71
|
+
ctx, stop := signal.NotifyContext(context.Background(),
|
|
72
|
+
syscall.SIGINT, syscall.SIGTERM)
|
|
73
|
+
defer stop()
|
|
74
|
+
|
|
75
|
+
if err := cmd.Execute(ctx); err != nil {
|
|
76
|
+
slog.Error("fatal", slog.Any("err", err))
|
|
77
|
+
os.Exit(1)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
That is the entire `main`. Anything more is a smell.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## `internal/config/config.go`
|
|
87
|
+
|
|
88
|
+
```go
|
|
89
|
+
package config
|
|
90
|
+
|
|
91
|
+
import (
|
|
92
|
+
"time"
|
|
93
|
+
"github.com/caarlos0/env/v11"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
type Config struct {
|
|
97
|
+
Host string `env:"HOST" envDefault:"0.0.0.0"`
|
|
98
|
+
Port int `env:"PORT" envDefault:"8080"`
|
|
99
|
+
DatabaseURL string `env:"DATABASE_URL,required"`
|
|
100
|
+
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"15s"`
|
|
101
|
+
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"30s"`
|
|
102
|
+
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"20s"`
|
|
103
|
+
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
|
104
|
+
LogFormat string `env:"LOG_FORMAT" envDefault:"json"`
|
|
105
|
+
Env string `env:"ENV" envDefault:"development"`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func Load() (Config, error) {
|
|
109
|
+
var cfg Config
|
|
110
|
+
if err := env.Parse(&cfg); err != nil {
|
|
111
|
+
return Config{}, err
|
|
112
|
+
}
|
|
113
|
+
return cfg, nil
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## `internal/obs/logger.go`
|
|
120
|
+
|
|
121
|
+
```go
|
|
122
|
+
package obs
|
|
123
|
+
|
|
124
|
+
import (
|
|
125
|
+
"context"
|
|
126
|
+
"log/slog"
|
|
127
|
+
"os"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
type ctxKey struct{ name string }
|
|
131
|
+
var requestIDKey = ctxKey{"request_id"}
|
|
132
|
+
|
|
133
|
+
func NewLogger(level, format string) *slog.Logger {
|
|
134
|
+
var lvl slog.Level
|
|
135
|
+
_ = lvl.UnmarshalText([]byte(level))
|
|
136
|
+
|
|
137
|
+
opts := &slog.HandlerOptions{Level: lvl, AddSource: true}
|
|
138
|
+
|
|
139
|
+
var h slog.Handler
|
|
140
|
+
switch format {
|
|
141
|
+
case "text":
|
|
142
|
+
h = slog.NewTextHandler(os.Stdout, opts)
|
|
143
|
+
default:
|
|
144
|
+
h = slog.NewJSONHandler(os.Stdout, opts)
|
|
145
|
+
}
|
|
146
|
+
return slog.New(&ctxHandler{Handler: h})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ctxHandler pulls request_id from ctx into every log line.
|
|
150
|
+
type ctxHandler struct{ slog.Handler }
|
|
151
|
+
|
|
152
|
+
func (h *ctxHandler) Handle(ctx context.Context, r slog.Record) error {
|
|
153
|
+
if id, ok := ctx.Value(requestIDKey).(string); ok && id != "" {
|
|
154
|
+
r.AddAttrs(slog.String("request_id", id))
|
|
155
|
+
}
|
|
156
|
+
return h.Handler.Handle(ctx, r)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func WithRequestID(ctx context.Context, id string) context.Context {
|
|
160
|
+
return context.WithValue(ctx, requestIDKey, id)
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## `internal/api/server.go`
|
|
167
|
+
|
|
168
|
+
```go
|
|
169
|
+
package api
|
|
170
|
+
|
|
171
|
+
import (
|
|
172
|
+
"context"
|
|
173
|
+
"fmt"
|
|
174
|
+
"log/slog"
|
|
175
|
+
"net/http"
|
|
176
|
+
|
|
177
|
+
"github.com/gin-gonic/gin"
|
|
178
|
+
"github.com/your-org/myservice/internal/api/handlers"
|
|
179
|
+
"github.com/your-org/myservice/internal/api/middleware"
|
|
180
|
+
"github.com/your-org/myservice/internal/config"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
type Server struct {
|
|
184
|
+
cfg config.Config
|
|
185
|
+
srv *http.Server
|
|
186
|
+
logger *slog.Logger
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func New(cfg config.Config, logger *slog.Logger, h *handlers.Handler) *Server {
|
|
190
|
+
gin.SetMode(gin.ReleaseMode)
|
|
191
|
+
r := gin.New()
|
|
192
|
+
|
|
193
|
+
// Middleware order matters — see "Middleware ordering" below.
|
|
194
|
+
r.Use(
|
|
195
|
+
middleware.RequestID(), // 1. assign request_id first
|
|
196
|
+
middleware.Recovery(logger), // 2. recovery wraps everything
|
|
197
|
+
middleware.RequestLogger(logger),
|
|
198
|
+
middleware.CORS(),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
h.Mount(r)
|
|
202
|
+
|
|
203
|
+
return &Server{
|
|
204
|
+
cfg: cfg,
|
|
205
|
+
logger: logger,
|
|
206
|
+
srv: &http.Server{
|
|
207
|
+
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
|
208
|
+
Handler: r,
|
|
209
|
+
ReadTimeout: cfg.ReadTimeout,
|
|
210
|
+
WriteTimeout: cfg.WriteTimeout,
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func (s *Server) Run(ctx context.Context) error {
|
|
216
|
+
errCh := make(chan error, 1)
|
|
217
|
+
go func() {
|
|
218
|
+
s.logger.InfoContext(ctx, "server starting",
|
|
219
|
+
slog.String("addr", s.srv.Addr))
|
|
220
|
+
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
221
|
+
errCh <- err
|
|
222
|
+
}
|
|
223
|
+
close(errCh)
|
|
224
|
+
}()
|
|
225
|
+
|
|
226
|
+
select {
|
|
227
|
+
case <-ctx.Done():
|
|
228
|
+
s.logger.InfoContext(ctx, "shutdown signal received")
|
|
229
|
+
shutdownCtx, cancel := context.WithTimeout(
|
|
230
|
+
context.Background(), s.cfg.ShutdownTimeout)
|
|
231
|
+
defer cancel()
|
|
232
|
+
return s.srv.Shutdown(shutdownCtx)
|
|
233
|
+
case err := <-errCh:
|
|
234
|
+
return err
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Notes:
|
|
240
|
+
|
|
241
|
+
- `gin.New()` not `gin.Default()` — `Default()` adds `Logger()` (text format, not slog) and `Recovery()` (no logger injection). We replace both.
|
|
242
|
+
- `gin.SetMode(gin.ReleaseMode)` silences debug output. Production assumed.
|
|
243
|
+
- `http.Server` with explicit timeouts. The default `nil` timeouts are a DoS waiting to happen.
|
|
244
|
+
- Graceful shutdown: SIGINT/SIGTERM cancels the ctx → `Shutdown(shutdownCtx)` gives in-flight requests up to `ShutdownTimeout` to finish.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Middleware ordering — the rule that actually matters
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
RequestID → Recovery → Logger → CORS → Auth → Handler
|
|
252
|
+
(1) (2) (3) (4) (5)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
1. **RequestID** is first so every subsequent middleware sees it.
|
|
256
|
+
2. **Recovery** wraps everything after it. Order: a panic in CORS still gets caught.
|
|
257
|
+
3. **Logger** sees the request_id and the recovered panic.
|
|
258
|
+
4. **CORS** before Auth — OPTIONS preflight must return without auth.
|
|
259
|
+
5. **Auth** is the last cross-cutting middleware. Per-route auth (admin-only) is mounted on a sub-router with extra middleware.
|
|
260
|
+
|
|
261
|
+
```go
|
|
262
|
+
// Public routes — no auth
|
|
263
|
+
api := r.Group("/api/v1")
|
|
264
|
+
{
|
|
265
|
+
api.POST("/auth/login", h.Login)
|
|
266
|
+
api.GET("/healthz", h.Healthz)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Authenticated routes
|
|
270
|
+
authed := r.Group("/api/v1", middleware.Auth(authSvc))
|
|
271
|
+
{
|
|
272
|
+
authed.GET("/users/:id", h.GetUser)
|
|
273
|
+
authed.POST("/users", h.CreateUser)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Admin-only routes
|
|
277
|
+
admin := r.Group("/api/v1/admin",
|
|
278
|
+
middleware.Auth(authSvc),
|
|
279
|
+
middleware.RequireRole("admin"))
|
|
280
|
+
{
|
|
281
|
+
admin.GET("/users", h.ListAllUsers)
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Middleware examples
|
|
288
|
+
|
|
289
|
+
### `middleware/request_id.go`
|
|
290
|
+
|
|
291
|
+
```go
|
|
292
|
+
package middleware
|
|
293
|
+
|
|
294
|
+
import (
|
|
295
|
+
"github.com/gin-gonic/gin"
|
|
296
|
+
"github.com/google/uuid"
|
|
297
|
+
"github.com/your-org/myservice/internal/obs"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
func RequestID() gin.HandlerFunc {
|
|
301
|
+
return func(c *gin.Context) {
|
|
302
|
+
id := c.GetHeader("X-Request-ID")
|
|
303
|
+
if id == "" {
|
|
304
|
+
id = uuid.Must(uuid.NewV7()).String()
|
|
305
|
+
}
|
|
306
|
+
c.Request = c.Request.WithContext(obs.WithRequestID(c.Request.Context(), id))
|
|
307
|
+
c.Header("X-Request-ID", id)
|
|
308
|
+
c.Next()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### `middleware/recovery.go`
|
|
314
|
+
|
|
315
|
+
```go
|
|
316
|
+
package middleware
|
|
317
|
+
|
|
318
|
+
import (
|
|
319
|
+
"log/slog"
|
|
320
|
+
"net/http"
|
|
321
|
+
"runtime/debug"
|
|
322
|
+
|
|
323
|
+
"github.com/gin-gonic/gin"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
func Recovery(logger *slog.Logger) gin.HandlerFunc {
|
|
327
|
+
return func(c *gin.Context) {
|
|
328
|
+
defer func() {
|
|
329
|
+
if r := recover(); r != nil {
|
|
330
|
+
logger.ErrorContext(c.Request.Context(), "panic recovered",
|
|
331
|
+
slog.Any("panic", r),
|
|
332
|
+
slog.String("stack", string(debug.Stack())),
|
|
333
|
+
)
|
|
334
|
+
if !c.Writer.Written() {
|
|
335
|
+
c.JSON(http.StatusInternalServerError,
|
|
336
|
+
gin.H{"error": "internal_error"})
|
|
337
|
+
}
|
|
338
|
+
c.Abort()
|
|
339
|
+
}
|
|
340
|
+
}()
|
|
341
|
+
c.Next()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### `middleware/request_logging.go`
|
|
347
|
+
|
|
348
|
+
```go
|
|
349
|
+
func RequestLogger(logger *slog.Logger) gin.HandlerFunc {
|
|
350
|
+
return func(c *gin.Context) {
|
|
351
|
+
start := time.Now()
|
|
352
|
+
c.Next()
|
|
353
|
+
logger.InfoContext(c.Request.Context(), "http request",
|
|
354
|
+
slog.String("method", c.Request.Method),
|
|
355
|
+
slog.String("path", c.Request.URL.Path),
|
|
356
|
+
slog.Int("status", c.Writer.Status()),
|
|
357
|
+
slog.Int("bytes", c.Writer.Size()),
|
|
358
|
+
slog.Duration("elapsed", time.Since(start)),
|
|
359
|
+
slog.String("ip", c.ClientIP()),
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The `sloglint` linter enforces typed attrs (`slog.String(...)`) over `slog.Any("path", ...)`. Keep the form.
|
|
366
|
+
|
|
367
|
+
### `middleware/cors.go`
|
|
368
|
+
|
|
369
|
+
```go
|
|
370
|
+
func CORS() gin.HandlerFunc {
|
|
371
|
+
return func(c *gin.Context) {
|
|
372
|
+
c.Header("Access-Control-Allow-Origin", "*")
|
|
373
|
+
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
374
|
+
c.Header("Access-Control-Allow-Headers", "*")
|
|
375
|
+
if c.Request.Method == http.MethodOptions {
|
|
376
|
+
c.AbortWithStatus(http.StatusNoContent)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
c.Next()
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Note the explicit OPTIONS short-circuit — preflight must NOT go through Auth.
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## Handlers — the canonical shape
|
|
389
|
+
|
|
390
|
+
```go
|
|
391
|
+
package handlers
|
|
392
|
+
|
|
393
|
+
import (
|
|
394
|
+
"errors"
|
|
395
|
+
"net/http"
|
|
396
|
+
|
|
397
|
+
"github.com/gin-gonic/gin"
|
|
398
|
+
"github.com/go-playground/validator/v10"
|
|
399
|
+
"github.com/your-org/myservice/internal/domain"
|
|
400
|
+
"github.com/your-org/myservice/internal/httperr"
|
|
401
|
+
"github.com/your-org/myservice/internal/service"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
type Handler struct {
|
|
405
|
+
Users *service.UserService
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
func (h *Handler) Mount(r gin.IRouter) {
|
|
409
|
+
api := r.Group("/api/v1")
|
|
410
|
+
api.POST("/users", h.CreateUser)
|
|
411
|
+
api.GET("/users/:id", h.GetUser)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
type createUserReq struct {
|
|
415
|
+
Email string `json:"email" binding:"required,email"`
|
|
416
|
+
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
func (h *Handler) CreateUser(c *gin.Context) {
|
|
420
|
+
var req createUserReq
|
|
421
|
+
if err := c.ShouldBindJSON(&req); err != nil {
|
|
422
|
+
writeBindingError(c, err)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
email, err := domain.NewEmail(req.Email)
|
|
427
|
+
if err != nil {
|
|
428
|
+
httperr.Write(c, err)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
username, err := domain.NewUsername(req.Username)
|
|
432
|
+
if err != nil {
|
|
433
|
+
httperr.Write(c, err)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
user, err := h.Users.Create(c.Request.Context(), email, username)
|
|
438
|
+
if err != nil {
|
|
439
|
+
httperr.Write(c, err)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
c.JSON(http.StatusCreated, user)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
func writeBindingError(c *gin.Context, err error) {
|
|
446
|
+
var vErr validator.ValidationErrors
|
|
447
|
+
if errors.As(err, &vErr) {
|
|
448
|
+
out := make(map[string]string, len(vErr))
|
|
449
|
+
for _, fe := range vErr {
|
|
450
|
+
out[fe.Field()] = fe.Tag()
|
|
451
|
+
}
|
|
452
|
+
c.JSON(http.StatusBadRequest, gin.H{"errors": out})
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
See `data-modeling.md` for the validator tag reference; see `error-handling.md` for the `httperr.Write` funnel.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## SSE streaming — the production pattern
|
|
464
|
+
|
|
465
|
+
CLIProxyAPI streams OpenAI-compatible SSE for hundreds of concurrent clients. The pattern:
|
|
466
|
+
|
|
467
|
+
```go
|
|
468
|
+
func (h *Handler) StreamChat(c *gin.Context) {
|
|
469
|
+
ctx, cancel := context.WithCancel(c.Request.Context())
|
|
470
|
+
defer cancel()
|
|
471
|
+
|
|
472
|
+
// 1. Set SSE headers BEFORE writing any body
|
|
473
|
+
c.Header("Content-Type", "text/event-stream")
|
|
474
|
+
c.Header("Cache-Control", "no-cache")
|
|
475
|
+
c.Header("Connection", "keep-alive")
|
|
476
|
+
c.Header("X-Accel-Buffering", "no") // disable nginx buffering
|
|
477
|
+
|
|
478
|
+
// 2. Obtain the flusher — REQUIRED for streaming
|
|
479
|
+
flusher, ok := c.Writer.(http.Flusher)
|
|
480
|
+
if !ok {
|
|
481
|
+
httperr.Write(c, errors.New("streaming unsupported"))
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 3. Pull chunks from upstream
|
|
486
|
+
chunks, errs := h.svc.StreamCompletions(ctx, req)
|
|
487
|
+
|
|
488
|
+
for {
|
|
489
|
+
select {
|
|
490
|
+
case <-ctx.Done():
|
|
491
|
+
return // client disconnected, ctx cancelled
|
|
492
|
+
case chunk, ok := <-chunks:
|
|
493
|
+
if !ok {
|
|
494
|
+
fmt.Fprint(c.Writer, "data: [DONE]\n\n")
|
|
495
|
+
flusher.Flush()
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
|
|
499
|
+
flusher.Flush()
|
|
500
|
+
case err := <-errs:
|
|
501
|
+
// Error mid-stream — emit as SSE event and bail
|
|
502
|
+
fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", err.Error())
|
|
503
|
+
flusher.Flush()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Key facts:
|
|
511
|
+
|
|
512
|
+
- **Headers MUST be set before the first `Write`.** Otherwise gin auto-sets `Content-Type: text/plain`.
|
|
513
|
+
- **`c.Writer.(http.Flusher)` is the streaming primitive.** Without `flusher.Flush()`, the response is buffered and arrives as one blob at the end.
|
|
514
|
+
- **Always respond to `<-ctx.Done()`.** A disconnected client must stop upstream work — otherwise you generate tokens for nothing.
|
|
515
|
+
- **The trailing `\n\n` per event is wire-mandatory** for SSE parsing. Missing it = the client never sees the event.
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## WebSocket upgrade
|
|
520
|
+
|
|
521
|
+
```go
|
|
522
|
+
import "github.com/gorilla/websocket" // still the canonical WS lib in 2026
|
|
523
|
+
|
|
524
|
+
var upgrader = websocket.Upgrader{
|
|
525
|
+
ReadBufferSize: 4096,
|
|
526
|
+
WriteBufferSize: 4096,
|
|
527
|
+
CheckOrigin: func(r *http.Request) bool {
|
|
528
|
+
// tighten in production
|
|
529
|
+
return true
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
func (h *Handler) WebSocketEcho(c *gin.Context) {
|
|
534
|
+
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
535
|
+
if err != nil {
|
|
536
|
+
slog.ErrorContext(c.Request.Context(), "ws upgrade failed", slog.Any("err", err))
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
defer conn.Close()
|
|
540
|
+
|
|
541
|
+
for {
|
|
542
|
+
mt, msg, err := conn.ReadMessage()
|
|
543
|
+
if err != nil { return }
|
|
544
|
+
if err := conn.WriteMessage(mt, msg); err != nil { return }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
For long-lived connections, use `conn.SetReadDeadline` + `SetPongHandler` for keepalive. CLIProxyAPI's `wsrelay` package is a reference implementation.
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## Database wiring — pgx pool, injected, never global
|
|
554
|
+
|
|
555
|
+
```go
|
|
556
|
+
package store
|
|
557
|
+
|
|
558
|
+
import (
|
|
559
|
+
"context"
|
|
560
|
+
"fmt"
|
|
561
|
+
"github.com/jackc/pgx/v5/pgxpool"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
|
565
|
+
cfg, err := pgxpool.ParseConfig(dsn)
|
|
566
|
+
if err != nil {
|
|
567
|
+
return nil, fmt.Errorf("parse dsn: %w", err)
|
|
568
|
+
}
|
|
569
|
+
cfg.MaxConns = 25
|
|
570
|
+
cfg.MinConns = 5
|
|
571
|
+
cfg.MaxConnLifetime = time.Hour
|
|
572
|
+
cfg.MaxConnIdleTime = 30 * time.Minute
|
|
573
|
+
|
|
574
|
+
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
575
|
+
if err != nil {
|
|
576
|
+
return nil, fmt.Errorf("connect: %w", err)
|
|
577
|
+
}
|
|
578
|
+
if err := pool.Ping(ctx); err != nil {
|
|
579
|
+
pool.Close()
|
|
580
|
+
return nil, fmt.Errorf("ping: %w", err)
|
|
581
|
+
}
|
|
582
|
+
return pool, nil
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
See `sqlc-pgx.md` for queries.
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## Healthcheck
|
|
591
|
+
|
|
592
|
+
```go
|
|
593
|
+
func (h *Handler) Healthz(c *gin.Context) {
|
|
594
|
+
if err := h.pool.Ping(c.Request.Context()); err != nil {
|
|
595
|
+
c.JSON(503, gin.H{"db": "down", "error": err.Error()})
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
c.JSON(200, gin.H{"ok": true})
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
Mount BEFORE auth. Health checks must be unauthenticated.
|
|
603
|
+
|
|
604
|
+
---
|
|
605
|
+
|
|
606
|
+
## Testing the server
|
|
607
|
+
|
|
608
|
+
```go
|
|
609
|
+
func TestCreateUser_returns_201_for_valid_input(t *testing.T) {
|
|
610
|
+
// Given
|
|
611
|
+
h := newTestHandler(t)
|
|
612
|
+
r := gin.New()
|
|
613
|
+
h.Mount(r)
|
|
614
|
+
|
|
615
|
+
body := `{"email":"a@b.com","username":"alice"}`
|
|
616
|
+
req := httptest.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
|
|
617
|
+
req.Header.Set("Content-Type", "application/json")
|
|
618
|
+
rec := httptest.NewRecorder()
|
|
619
|
+
|
|
620
|
+
// When
|
|
621
|
+
r.ServeHTTP(rec, req)
|
|
622
|
+
|
|
623
|
+
// Then
|
|
624
|
+
require.Equal(t, http.StatusCreated, rec.Code)
|
|
625
|
+
var got struct{ ID string `json:"id"` }
|
|
626
|
+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
627
|
+
require.NotEmpty(t, got.ID)
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
See `testing.md` for full patterns (testcontainers integration, table-driven, goleak).
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## Sources
|
|
636
|
+
|
|
637
|
+
- gin docs: https://gin-gonic.com/docs/
|
|
638
|
+
- CLIProxyAPI (reference impl): https://github.com/router-for-me/CLIProxyAPI
|
|
639
|
+
- pgx pool: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool
|
|
640
|
+
- SSE spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
641
|
+
- Go's `http.Server` graceful shutdown: https://pkg.go.dev/net/http#Server.Shutdown
|