diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 461d670..23eb2e1 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -102,9 +102,10 @@ reviews: - Flag any technical claim a reviewer cannot verify from the celeris source, a linked issue, or a committed benchmark cell. Say what evidence would settle it. Do not accept "fast", "zero-copy", "lock-free", "SIMD", "no allocations" or similar without a source. - - Pages cite source locations such as `celeris/internal/resource/config.go:13-19`. Check that the - file exists and that those lines still say what the page claims; line numbers drift as - celeris changes. + - Pages cite source locations by file and symbol, such as + `celeris/internal/resource/config.go` (`Config.WithDefaults`), never by line number. Check + that the file exists, that the symbol is declared in it, and that it says what the page + claims. Do not ask for line numbers: they drift as celeris changes. - Go code samples must compile against the current celeris API: correct imports, types, method names and error handling. Flag samples that would not compile or that teach an unsafe pattern. diff --git a/scripts/docs-symbol-citations.test.ts b/scripts/docs-symbol-citations.test.ts new file mode 100644 index 0000000..8055550 --- /dev/null +++ b/scripts/docs-symbol-citations.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +// Ratchet for goceleris/celeris#891 items 8 and 10: docs cite celeris code by +// symbol (`celeris/.go` (`Symbol`)), never by line number (`path.go:N`). +// ALLOW lists the pages that still carry line-number locators, with the exact +// number of locators each page still has. Each cleanup PR removes its pages +// from ALLOW; a page that reaches zero must be removed, and a count that drifts +// fails the test in either direction. +const DOCS = resolve(import.meta.dir, "..", "src", "content", "docs"); +const LOCATOR = /\.go:\d+/g; +// Continuations of a .go locator that the locator count does not see. A page +// that cites a symbol must not keep the rest of an old multi-locator sentence: +// - after a .go path on the same line: a backticked range or :N fragment +// (`x.go:1-2`, `3-4`; `x.go` ... `:180`), or a comma list (x.go:1-2, 3-4); +// - a range wrapped to the next line, with or without its opening backtick +// (`x.go:1-2,` then `3-4`); +// - a wrapped :N fragment on a line that follows a .go path (`:283`). +// A listen address such as `:8080` or `:0` is not a fragment: it is flagged +// only after a .go path on the same line, or when it opens a line that follows +// one (so reword a prose line that must start with a listen address). +const SAME_LINE = /\.go[^\n]*`(?::\d+(?:-\d+)?|\d+-\d+)`|\.go:\d+(?:-\d+)?,/; +const WRAPPED = /^\d+(?:-\d+)?`/; +const WRAPPED_AFTER_GO = /^[(\s]*`(?::\d+(?:-\d+)?|\d+-\d+)`/; + +export function continuationLines(text: string): number[] { + const lines = text.split("\n"); + const hits: number[] = []; + lines.forEach((line, i) => { + const prev = i > 0 ? lines[i - 1] : ""; + if (SAME_LINE.test(line) || WRAPPED.test(line) || (prev.includes(".go") && WRAPPED_AFTER_GO.test(line))) { + hits.push(i + 1); + } + }); + return hits; +} + +const ALLOW: Record = { + "binding-and-validation.md": 1, + "context-api-reference.md": 1, + "data-stores.md": 37, + "deployment.md": 34, + "engines.md": 20, + "graceful-shutdown.md": 4, + "middleware-auth.md": 1, + "middleware-content.md": 25, + "middleware-routing-helpers.md": 37, + "middleware.md": 21, + "net-http-interop.md": 16, + "observability.md": 31, + "performance.md": 36, + "routing.md": 28, + "sse.md": 26, + "streaming.md": 15, + "testing.md": 17, +}; + +function pages(): string[] { + return readdirSync(DOCS).filter((f) => f.endsWith(".md") || f.endsWith(".mdx")).sort(); +} + +function locatorsIn(file: string): number { + return (readFileSync(join(DOCS, file), "utf8").match(LOCATOR) ?? []).length; +} + +test("no page outside ALLOW cites a celeris line number", () => { + const offenders = pages().filter((f) => !(f in ALLOW) && locatorsIn(f) > 0); + expect(offenders).toEqual([]); +}); + +test("each allowlisted page has exactly its recorded locator count", () => { + const drift = Object.entries(ALLOW) + .map(([f, n]) => [f, n, locatorsIn(f)] as const) + .filter(([, want, got]) => want !== got); + expect(drift).toEqual([]); +}); + +test("no page outside ALLOW carries a continuation range after a .go locator", () => { + const hits: string[] = []; + for (const f of pages().filter((p) => !(p in ALLOW))) { + for (const n of continuationLines(readFileSync(join(DOCS, f), "utf8"))) hits.push(`${f}:${n}`); + } + expect(hits).toEqual([]); +}); + +// Negative controls for the detector itself: every leftover shape is flagged, +// and a listen address is not. +test("the continuation detector flags each leftover fragment shape", () => { + const flagged = [ + "(`celeris/x/config.go` (`Config.A`) and `:180`):", + "(`celeris/x/conn.go` (`Conn.ReadMessage`), `:411`).", + "Source: `celeris/x/sse.go` (`New`), `:560-562`.", + "see `celeris/x/config.go` (`Config`), `243-245`).", + "`celeris/x/config.go` (`CLFConfig`),\n`:283`):", + "`celeris/x/config.go` (`defaultEngine`),\n243-245`).", + "`celeris/x/config.go` (`F`),\n`243-245`).", + "x/config.go:13-19, 243-245", + ]; + for (const text of flagged) expect(continuationLines(text)).not.toEqual([]); +}); + +test("the continuation detector leaves listen addresses and plain prose alone", () => { + const clean = [ + "An empty `Addr` becomes `:8080` (`celeris/internal/resource/config.go` (`Config.WithDefaults`)).", + "- `:0` binds an OS-assigned ephemeral port.", + "`celeris/x/config.go` (`CLFConfig`) and `celeris/x/config.go` (`JSONConfig`).", + ]; + const lonely = "`celeris/x/config.go` (`WithDefaults`):\n`0` falls through to the default branch."; + expect(continuationLines(lonely)).toEqual([]); + for (const text of clean) expect(continuationLines(text)).toEqual([]); +}); diff --git a/src/content/docs/configuration.md b/src/content/docs/configuration.md index df77c42..02979b2 100644 --- a/src/content/docs/configuration.md +++ b/src/content/docs/configuration.md @@ -24,9 +24,9 @@ s := celeris.New(celeris.Config{ }) ``` -The `Config` struct is defined in `celeris/config.go:73-277`. Defaults are filled in -at `Start` by `celeris/internal/resource/config.go:239` (`WithDefaults`) and validated by -`celeris/internal/resource/config.go:148` (`Validate`). +The `Config` struct is defined in `celeris/config.go` (`Config`). Defaults are filled in +at `Start` by `celeris/internal/resource/config.go` (`WithDefaults`) and validated by +`celeris/internal/resource/config.go` (`Validate`). ## The zero / `-1` convention @@ -56,10 +56,10 @@ celeris.Config{ } ``` -The mapping happens in `WithDefaults` (`celeris/internal/resource/config.go:279-284`, `294-303`, `317-326`): +The mapping happens in `WithDefaults` (`celeris/internal/resource/config.go` (`Config.WithDefaults`, `resolveTimeout`)): `0` falls through to the default branch; a negative value is normalised to `0`, which internally means "off". Validation rejects anything below `-1` -(`celeris/internal/resource/config.go:191-199`), so `-2`, `-5s`, etc. are config errors. +(`celeris/internal/resource/config.go` (`Config.Validate`)), so `-2`, `-5s`, etc. are config errors. ## Network, protocol, and engine @@ -75,13 +75,13 @@ celeris.Config{Addr: "127.0.0.1:9000"} // bind to loopback only | ------ | -------- | --------- | -------------------------------------------------- | | `Addr` | `string` | `":8080"` | TCP address in `host:port` form. Empty → `:8080`. | -- An empty `Addr` becomes `:8080` (`celeris/internal/resource/config.go:240-242`). +- An empty `Addr` becomes `:8080` (`celeris/internal/resource/config.go` (`Config.WithDefaults`)). - `:0` binds an OS-assigned ephemeral port — handy for tests and for letting a process manager pick the port. Retrieve the chosen address from the server after it starts if you need it. - Validation parses the `host:port` split and requires the port to be `0–65535`; a malformed address or out-of-range port is a config error - (`celeris/internal/resource/config.go:151-161`). + (`celeris/internal/resource/config.go` (`Config.Validate`)). ### `Protocol` @@ -95,8 +95,8 @@ in front of it — see [Deployment](/docs/deployment). | `celeris.HTTP1` | HTTP/1.1 only | | `celeris.H2C` | HTTP/2 cleartext (h2c) only | -Defined in `celeris/config.go:15-22`. The zero value resolves to `Auto` -(`celeris/internal/resource/config.go:252-254`). `Protocol` interacts with `EnableH2Upgrade` +Defined in `celeris/config.go` (`Protocol`). The zero value resolves to `Auto` +(`celeris/internal/resource/config.go` (`Config.WithDefaults`)). `Protocol` interacts with `EnableH2Upgrade` (see [below](#enableh2upgrade)). ### `Engine` @@ -111,13 +111,12 @@ The I/O engine that drives the accept/read/write loop. The native engines are | `celeris.IOUring` | Linux 5.10+ | io_uring async I/O | | `celeris.Std` | any platform | **Default off-Linux.** Go `net/http` server | -Defined in `celeris/config.go:30-40`. The zero value resolves to `Adaptive` on -Linux and `Std` everywhere else (`celeris/internal/resource/config.go:13-19`, -`243-245`). +Defined in `celeris/config.go` (`EngineType`). The zero value resolves to `Adaptive` on +Linux and `Std` everywhere else (`celeris/internal/resource/config.go` (`defaultEngine`, `Config.WithDefaults`)). **Off-Linux hard error.** If you explicitly request `Adaptive`, `Epoll`, or `IOUring` on macOS, Windows, or any non-Linux OS, `Start` fails validation with an -error like `engine epoll requires Linux` (`celeris/internal/resource/config.go:201-208`). +error like `engine epoll requires Linux` (`celeris/internal/resource/config.go` (`Config.Validate`)). Leave `Engine` zero and you transparently get `Std` off-Linux, so the same code runs everywhere — only override `Engine` when you know you are on Linux. @@ -143,7 +142,7 @@ the `Std`/`Epoll`/`IOUring` engines. | `celeris.WorkloadLowConcurrency` | Thin/latency-sensitive traffic — start and stay on epoll | | `celeris.WorkloadHighConcurrency` | Many H1 keep-alive conns/worker — start on io_uring (when kernel + `RLIMIT_MEMLOCK` allow) | -Defined in `celeris/config.go:45-62` and `celeris/internal/resource/resource.go:1-25`. +Defined in `celeris/config.go` (`WorkloadHint`) and `celeris/internal/resource/resource.go` (`WorkloadHint`). Since v1.5.6 established connections **transplant** between epoll and io_uring when Adaptive switches (both directions), so the *start* engine no longer fixes keep-alive throughput — but concurrency is unknowable when the server binds (no @@ -170,9 +169,9 @@ celeris.Config{Workers: 8} // 8 I/O worker goroutines | `Workers` | `int` | `GOMAXPROCS` | `>= 2` if set (`MinWorkers`) | The number of I/O worker goroutines. Zero means `GOMAXPROCS` -(`celeris/internal/resource/preset.go:17-23`, `celeris/internal/resource/resource.go:92-117`). If you set it, it must be `>= 2`; +(`celeris/internal/resource/preset.go` (`resolveDefaults`), `celeris/internal/resource/resource.go` (`Resources.Resolve`)). If you set it, it must be `>= 2`; `Workers: 1` is a config error (`workers must be >= 2 if set`, -`celeris/internal/resource/config.go:179-181`, `celeris/internal/resource/preset.go:7-8`). +`celeris/internal/resource/config.go` (`Config.Validate`), `celeris/internal/resource/preset.go` (`MinWorkers`)). ## Timeouts @@ -186,7 +185,8 @@ All timeouts follow the [zero / `-1` convention](#the-zero---1-convention). | `IdleTimeout` | `time.Duration` | `600s` | no idle timeout | Max idle time on a keep-alive connection | | `ShutdownTimeout` | `time.Duration` | `30s` | — | Drain deadline on graceful shutdown | -Sources: `celeris/config.go:91-115`, defaults at `celeris/internal/resource/config.go:288-303`. +Sources: `celeris/config.go` (`Config.ReadTimeout`, `Config.ReadHeaderTimeout`, `Config.WriteTimeout`, `Config.IdleTimeout`, `Config.ShutdownTimeout`), +defaults at `celeris/internal/resource/config.go` (`Config.WithDefaults`). ### `ReadHeaderTimeout` is your slow-loris defence @@ -198,8 +198,8 @@ worker and a listener-backlog slot for the full `ReadTimeout` window. The defaul of 10s defeats slow-loris while still letting legitimate proxies and high-latency (satellite) clients finish their headers. The std engine wires this to `http.Server.ReadHeaderTimeout`; the io_uring/epoll engines enforce the same budget -inside their H1 header read loop (`celeris/config.go:94-104`, -`celeris/internal/resource/config.go:45-61`). +inside their H1 header read loop (`celeris/config.go` (`Config.ReadHeaderTimeout`), +`celeris/internal/resource/config.go` (`Config.ReadHeaderTimeout`)). ### SSE needs no `-1`; other long-lived responses do @@ -306,9 +306,9 @@ celeris.Config{MaxRequestBodySize: -1} // no limit (use with care) The maximum **memory** used for multipart form parsing, **per request** (default 32 MB, the same as `net/http`). Zero applies the default -(`DefaultMaxFormSize`, `celeris/types.go:10-12`); a negative value (`-1`) disables -the limit, parsing with no in-memory ceiling (`celeris/config.go:117-119`, -`celeris/handler.go:160-162`, enforced at `celeris/context_request.go:621-623`). It +(`DefaultMaxFormSize`, `celeris/types.go` (`DefaultMaxFormSize`)); a negative value (`-1`) disables +the limit, parsing with no in-memory ceiling (`celeris/config.go` (`Config.MaxFormSize`), +`celeris/handler.go` (`routerAdapter.HandleStream`), enforced at `celeris/context_request.go` (`Context.parseForm`)). It bounds the in-memory portion of `multipart/form-data` parsing — see [Request handling](/docs/request-handling) for form access (`FormValue`, files) and per-context overrides. @@ -321,8 +321,8 @@ celeris.Config{MaxFormSize: 8 << 20} // 8 MiB of in-memory form data per reques The maximum size of a request's header block (default 16 MB). If you set it, it must be **at least 4096**; a smaller positive value is a config error -(`maxHeaderBytes must be >= 4096 if set`, `celeris/config.go:132-133`, -`celeris/internal/resource/config.go:175-177`, `276-278`). +(`maxHeaderBytes must be >= 4096 if set`, `celeris/config.go` (`Config.MaxHeaderBytes`), +`celeris/internal/resource/config.go` (`Config.Validate`) and `celeris/internal/resource/config.go` (`Config.WithDefaults`)). ```go celeris.Config{MaxHeaderBytes: 64 << 10} // 64 KiB @@ -339,11 +339,12 @@ These apply when HTTP/2 (h2c) is in play — via `Protocol: H2C`, an h2c upgrade | `MaxFrameSize` | `uint32` | `1 MiB` (`1 << 20`) | `16384 – 16777215` if set | | `InitialWindowSize` | `uint32` | `1 MiB` (`1 << 20`) | `<= 2147483647` | -Sources: `celeris/config.go:126-131`, defaults at `celeris/internal/resource/config.go:258-275`, -validation at `celeris/internal/resource/config.go:163-173`. +Sources: `celeris/config.go` (`Config.MaxConcurrentStreams`, `Config.MaxFrameSize`, `Config.InitialWindowSize`), +defaults at `celeris/internal/resource/config.go` (`Config.WithDefaults`), +validation at `celeris/internal/resource/config.go` (`Config.Validate`). The real defaults for both `MaxFrameSize` and `InitialWindowSize` are **1 MiB** -(`1 << 20`), as set by `WithDefaults` (`celeris/internal/resource/config.go:258-272`). +(`1 << 20`), as set by `WithDefaults` (`celeris/internal/resource/config.go` (`Config.WithDefaults`)). ```go celeris.Config{ @@ -367,23 +368,24 @@ the floor. | `SocketSendBuf` | `int` | OS default (0) | — | `SO_SNDBUF` for accepted connections | | `MaxConns` | `int` | unlimited (0) | — | Max simultaneous connections **per worker** | -Sources: `celeris/config.go:135-164`, `celeris/internal/resource/resource.go:27-54`, -`celeris/internal/resource/preset.go:11-14`. +Sources: `celeris/config.go` (`Config.DisableKeepAlive`, `Config.BufferSize`, `Config.SocketRecvBuf`, `Config.SocketSendBuf`, `Config.MaxConns`), +`celeris/internal/resource/resource.go` (`Resources`), +`celeris/internal/resource/preset.go` (`MinBufferSize`). - **`DisableKeepAlive`** — when `true`, each request runs on its own connection and the connection closes after the response. Leave `false` (the default) for performance; keep-alive is what makes the engine fast. - **`BufferSize`** — the per-connection I/O buffer. Zero uses the engine default - (8192 bytes, `celeris/internal/resource/preset.go:26`). If you set it, a value below 4096 is + (8192 bytes, `celeris/internal/resource/preset.go` (`resolveDefaults`)). If you set it, a value below 4096 is a config error (`bufferSize must be >= 4096 if set`, - `celeris/internal/resource/config.go:183-185`), while a value above 262144 is silently - clamped down to that ceiling (`MaxBufferSize`, `celeris/internal/resource/resource.go:114`). + `celeris/internal/resource/config.go` (`Config.Validate`)), while a value above 262144 is silently + clamped down to that ceiling (`MaxBufferSize`, `celeris/internal/resource/resource.go` (`Resources.Resolve`)). - **`SocketRecvBuf` / `SocketSendBuf`** — set `SO_RCVBUF` / `SO_SNDBUF` on accepted sockets. **Leave them at 0** unless you have a measured reason: 0 lets the kernel's TCP auto-tuning own buffer sizing (up to `net.ipv4.tcp_rmem`/`tcp_wmem` maxima), which is usually better. Forcing a fixed size can cap the receive window and *throttle* large-body POSTs on hosts where `net.core.rmem_max` is small - (`celeris/internal/resource/preset.go:29-36`). + (`celeris/internal/resource/preset.go` (`resolveDefaults`)). - **`MaxConns`** — the cap is **per worker**, not server-wide. With `Workers: 8` and `MaxConns: 1000`, the server tolerates up to ~8000 connections. Zero means unlimited. @@ -410,19 +412,19 @@ clips the peak-RSS balloon that a connection-ramp burst would otherwise produce. - **`0` (default)** — Celeris does **not** touch the process GC; the runtime default (`GOGC=100`, no limit) stands, and embedders keep full control - (`celeris/config.go:166-176`, applied at `celeris/server.go:970-971`). + (`celeris/config.go` (`Config.MemoryLimitBytes`), applied at `celeris/server.go` (`Server.doPrepare`)). - **`> 0`** — the GC collects *before* the heap balloons during a connection-ramp burst, trading a few extra GC cycles during the ramp for a lower high-water mark. Steady-state RSS sits far below the limit, so steady throughput is unaffected. A negative value is a config error - (`memoryLimitBytes must be >= 0`, `celeris/internal/resource/config.go:187-189`). + (`memoryLimitBytes must be >= 0`, `celeris/internal/resource/config.go` (`Config.Validate`)). > **`SetMemoryLimit` is process-global.** Set this only when Celeris owns the > process (e.g. a dedicated server binary), never when Celeris is embedded > alongside other heap-sensitive code that manages its own GC target. `celeris.DeriveMemoryLimit(workers)` returns a generous, ready-to-use ceiling — -`max(256 MiB, workers*32 MiB)` (`celeris/config.go:63-70`). It is sized **high** +`max(256 MiB, workers*32 MiB)` (`celeris/config.go` (`DeriveMemoryLimit`)). It is sized **high** on purpose: the goal is to clip the ramp balloon, not to run the heap tight (a too-tight limit GC-thrashes under load). It is never applied implicitly — you must pass it explicitly. @@ -439,7 +441,7 @@ s := celeris.New(cfg) Built-in metrics are **on by default**. Set `DisableMetrics: true` to turn off the collector — `Server.Collector()` then returns `nil` and per-request recording is -skipped (`celeris/config.go:178-181`, `celeris/server.go:152-154`, `863-868`). +skipped (`celeris/config.go` (`Config.DisableMetrics`), `celeris/server.go` (`New`, `Server.Collector`)). ```go snap := s.Collector().Snapshot() // requests, errors, latency, active conns, CPU @@ -452,7 +454,7 @@ s := celeris.New(celeris.Config{DisableMetrics: true}) The **server-level default** for how handlers are dispatched. When `false` (the default) handlers run inline on the I/O worker — best for CPU/cache-bound work. When `true`, handlers run on spawned goroutines so blocking I/O (DB drivers, upstream -HTTP, file reads) does not stall the event loop (`celeris/config.go:183-239`). +HTTP, file reads) does not stall the event loop (`celeris/config.go` (`Config.AsyncHandlers`)). Individual routes and groups override this with `Route.Async()` / `RouteGroup.Async()` (most-specific wins: route > group > server default). The common pattern is to keep @@ -478,7 +480,7 @@ celeris.Config{AsyncHandlers: true} // default async; mark hot CPU r > **Driver fast path keys off the *effective* async state.** Celeris drivers opened > `WithEngine(srv)` pick their netpoll-park fast path from whether the server is > *effectively* async — i.e. `AsyncHandlers: true` **or** any route opted in via -> `.Async()` / `.UsesDriver()` (`celeris/config.go:224-236`). The effective state is +> `.Async()` / `.UsesDriver()` (`celeris/config.go` (`Config.AsyncHandlers`)). The effective state is > read at driver construction, so if you keep `AsyncHandlers: false` and rely on > per-route `.Async()` / `.UsesDriver()`, **open the driver *after* registering those > routes** — or set `AsyncHandlers: true` to be order-independent. See @@ -489,7 +491,7 @@ celeris.Config{AsyncHandlers: true} // default async; mark hot CPU r Called when an H1 request carries `Expect: 100-continue`. Return `false` to respond `417 Expectation Failed` and skip reading the body; return `true` (or leave the callback `nil`) to send `100 Continue` and read the body -(`celeris/config.go:241-247`). +(`celeris/config.go` (`Config.OnExpectContinue`)). ```go celeris.Config{ @@ -502,7 +504,7 @@ celeris.Config{ ### `OnConnect` / `OnDisconnect` Connection lifecycle callbacks, invoked with the remote peer address when a -connection is accepted and closed (`celeris/config.go:249-254`). +connection is accepted and closed (`celeris/config.go` (`Config.OnConnect`, `Config.OnDisconnect`)). > **These run on the event loop — they must not block.** A slow `OnConnect` / > `OnDisconnect` stalls the I/O worker and degrades every connection it handles. Do @@ -520,8 +522,8 @@ celeris.Config{ ### `TrustedProxies` A list of trusted proxy CIDR ranges (or bare IPs). It controls how `Context.ClientIP()` -interprets `X-Forwarded-For` (`celeris/config.go:256-260`, parsed at -`celeris/server.go:909-924`). +interprets `X-Forwarded-For` (`celeris/config.go` (`Config.TrustedProxies`), parsed at +`celeris/server.go` (`Server.doPrepare`)). > **Security-critical.** When `TrustedProxies` is **empty**, `ClientIP()` trusts > proxy headers from *anyone* (legacy behaviour) — a client can spoof its IP by @@ -531,7 +533,7 @@ interprets `X-Forwarded-For` (`celeris/config.go:256-260`, parsed at Entries may be CIDR (`10.0.0.0/8`) or a bare IP (`192.168.1.10`, normalised to a `/32` or `/128`). An unparseable entry is a `Start` error -(`celeris: invalid TrustedProxies entry: …`, `celeris/server.go:909-916`). +(`celeris: invalid TrustedProxies entry: …`, `celeris/server.go` (`Server.doPrepare`)). ```go celeris.Config{ @@ -546,7 +548,7 @@ celeris.Config{ ### `Logger` The structured logger for server diagnostics. Defaults to `slog.Default()` when -`nil` (`celeris/config.go:262-263`, `celeris/internal/resource/config.go:285-287`). +`nil` (`celeris/config.go` (`Config.Logger`), `celeris/internal/resource/config.go` (`Config.WithDefaults`)). ```go celeris.Config{ @@ -559,7 +561,7 @@ celeris.Config{ A **`*bool`** (three-state) controlling whether the server honours RFC 7540 §3.2 `Upgrade: h2c` requests — promoting an HTTP/1 connection to cleartext HTTP/2. Being a pointer lets Celeris distinguish "not set" from "explicitly false" -(`celeris/config.go:265-276`, resolved at `celeris/config.go:332-341`): +(`celeris/config.go` (`Config.EnableH2Upgrade`), resolved at `celeris/config.go` (`Config.toResourceConfig`)): | Value | Behaviour | | ------------- | -------------------------------------------------------------------- | @@ -583,10 +585,10 @@ celeris.Config{ Configuration is validated **at `Start`** (and `StartWithContext` / `StartWithListener…`), not at `celeris.New`. `New` never fails; the validation runs inside the one-time prepare step and is reported as the error returned by `Start` -(`celeris/server.go:899-907`). All field errors are collected and joined, so one +(`celeris/server.go` (`Server.doPrepare`)). All field errors are collected and joined, so one `Start` call surfaces every problem at once, prefixed with `config validation:`. -Common errors under the `config validation:` prefix (`celeris/internal/resource/config.go:148-227`): +Common errors under the `config validation:` prefix (`celeris/internal/resource/config.go` (`Config.Validate`)): | Error message (substring) | Cause | | -------------------------------------------------- | ------------------------------------------------ | @@ -602,7 +604,7 @@ Common errors under the `config validation:` prefix (`celeris/internal/resource/ `TrustedProxies` is parsed *after* validation passes and surfaces its own error (not under the `config validation:` prefix): `celeris: invalid TrustedProxies entry: …` -for an unparseable CIDR/IP (`celeris/server.go:909-916`). +for an unparseable CIDR/IP (`celeris/server.go` (`Server.doPrepare`)). ```go s := celeris.New(celeris.Config{Workers: 1, Engine: celeris.IOUring}) // off-Linux @@ -637,7 +639,7 @@ if err := s.Start(); err != nil { Set the field to `-1`. Zero gives the default. **Where do defaults get applied?** -In `WithDefaults` (`celeris/internal/resource/config.go:239`), called by the server's prepare +In `WithDefaults` (`celeris/internal/resource/config.go` (`Config.WithDefaults`)), called by the server's prepare step at `Start`. You can read the source to see every default value. **Does `Start()` use `ShutdownTimeout`?** diff --git a/src/content/docs/middleware-traffic.md b/src/content/docs/middleware-traffic.md index 1b92f77..37ed591 100644 --- a/src/content/docs/middleware-traffic.md +++ b/src/content/docs/middleware-traffic.md @@ -49,7 +49,7 @@ each key gets a bucket that refills at `RPS` tokens per second up to a ceiling o `Burst` tokens; each request spends one token, and a request with no tokens left is rejected with `429`. This lets short bursts through (up to `Burst`) while holding the long-run average at `RPS`. -Source: `celeris/middleware/ratelimit/ratelimit.go:36`. +Source: `celeris/middleware/ratelimit/ratelimit.go` (`New`). ```go import "github.com/goceleris/celeris/middleware/ratelimit" @@ -66,7 +66,7 @@ s.Use(ratelimit.New(ratelimit.Config{ Instead of `RPS` + `Burst` you can write a `Rate` string in `-` form, where the unit is `S` (second), `M` (minute), `H` (hour), or `D` (day). `Rate` takes precedence over `RPS`, and sets `Burst` equal to the count **unless -you set `Burst` explicitly**. Source: `celeris/middleware/ratelimit/config.go:208`. +you set `Burst` explicitly**. Source: `celeris/middleware/ratelimit/config.go` (`ParseRate`). ```go s.Use(ratelimit.New(ratelimit.Config{Rate: "100-M"})) // 100/min, burst 100 @@ -95,13 +95,13 @@ if err := ratelimit.ValidateConfig(cfg); err != nil { ``` `ValidateConfig` is the safe pre-flight check; `New` itself **panics** on an -invalid `Rate`. Source: `celeris/middleware/ratelimit/config.go:179`. +invalid `Rate`. Source: `celeris/middleware/ratelimit/config.go` (`ValidateConfig`). ### The key: `KeyFunc` and the proxy caveat A limiter is only as good as its key. By default the key is the **client IP** (`c.ClientIP()`, falling back to `c.RemoteAddr()`). -Source: `celeris/middleware/ratelimit/config.go:155`. +Source: `celeris/middleware/ratelimit/config.go` (`applyDefaults`). > **Read this before you ship.** `c.ClientIP()` only returns the real client > address when the [proxy middleware](/docs/middleware) is installed @@ -111,7 +111,7 @@ Source: `celeris/middleware/ratelimit/config.go:155`. > noisy client triggers a global 429 for everyone behind that hop. With a > *misconfigured* `TrustedProxies` range, attackers can spoof `X-Forwarded-For` > and escape their bucket. Verify the chain before relying on the default. -> Source: the `KeyFunc` doc comment, `celeris/middleware/ratelimit/config.go:62`. +> Source: the `KeyFunc` doc comment, `celeris/middleware/ratelimit/config.go` (`Config.KeyFunc`). For anything other than raw IP, supply your own `KeyFunc`: @@ -140,14 +140,14 @@ limit headers, and rejected requests also get `Retry-After`: | `X-RateLimit-Reset` | Seconds until the bucket refills | | `Retry-After` | (on 429 only) Seconds the client should wait | -Source: `celeris/middleware/ratelimit/ratelimit.go:198-207`. +Source: `celeris/middleware/ratelimit/ratelimit.go` (`New`). ### Sliding window Token bucket can let a burst land right at a window edge. Set `SlidingWindow: true` to use a sliding-window counter instead — it weights the previous and current window by the elapsed fraction of the current window, -smoothing the rate near boundaries. Source: `celeris/middleware/ratelimit/config.go:91`. +smoothing the rate near boundaries. Source: `celeris/middleware/ratelimit/config.go` (`Config.SlidingWindow`). ```go s.Use(ratelimit.New(ratelimit.Config{Rate: "100-M", SlidingWindow: true})) @@ -159,8 +159,8 @@ When different callers deserve different limits (free vs. paid tiers, say), set `RateFunc`. It runs per request and returns a `Rate` string; an empty string falls back to the static `Rate`/`RPS`/`Burst`. Each distinct rate string gets its own limiter, cached up to `MaxDynamicLimiters` (default 1024); beyond that, new -rate strings are rejected. Source: `celeris/middleware/ratelimit/config.go:56`, -`celeris/middleware/ratelimit/ratelimit.go:176-188`. +rate strings are rejected. Source: `celeris/middleware/ratelimit/config.go` (`Config.RateFunc`), +`celeris/middleware/ratelimit/ratelimit.go` (`New`). ```go s.Use(ratelimit.New(ratelimit.Config{ @@ -184,7 +184,7 @@ The built-in limiter is in-process: each instance counts independently, so N replicas means N× the effective limit. To enforce a *global* limit across a fleet, plug in a `Store`. When `Store` is set, it owns the rate/burst logic and `RPS`, `Burst`, `Shards`, and `CleanupInterval` are ignored. -Source: `celeris/middleware/ratelimit/config.go:38`. +Source: `celeris/middleware/ratelimit/config.go` (`Config.Store`). Celeris ships a Redis-backed store at `github.com/goceleris/celeris/middleware/ratelimit/redisstore`: @@ -212,8 +212,8 @@ s.Use(ratelimit.New(ratelimit.Config{Store: store})) The Redis store uses an atomic Lua token-bucket script (loaded via `SCRIPT LOAD` at `New` time, then run with `EVALSHA` on the hot path), so the limit holds even under concurrent requests across replicas. Source: -`celeris/middleware/ratelimit/redisstore/redisstore.go:60-69` (the script), -`redisstore.go:113-115` (`New`). A memcached store ships alongside it at +`celeris/middleware/ratelimit/redisstore/redisstore.go` (`luaTokenBucket`) (the script), +`celeris/middleware/ratelimit/redisstore/redisstore.go` (`New`). A memcached store ships alongside it at `github.com/goceleris/celeris/middleware/ratelimit/memcachedstore`. ### Refunding tokens @@ -224,7 +224,7 @@ status `>= 400` or panics (a panic spent the token before celeris v1.6.0, [celeris#921](https://github.com/goceleris/celeris/issues/921)); `SkipSuccessfulRequests` refunds it for status `< 400`. For a `Store`, refunds require the store to implement `StoreUndo` (the Redis store -does). Source: `celeris/middleware/ratelimit/config.go:98-106`. +does). Source: `celeris/middleware/ratelimit/config.go` (`Config`). ```go // Only count requests that actually hit your backend (4xx don't burn quota). @@ -279,7 +279,7 @@ The breaker is a three-state machine: - **Half-open** — probing. After the cooldown, a few probe requests are let through. If they succeed the breaker closes; if any fails it re-opens. -Source: `celeris/middleware/circuitbreaker/config.go:9-19`. +Source: `celeris/middleware/circuitbreaker/config.go` (`State`). ```go import "github.com/goceleris/celeris/middleware/circuitbreaker" @@ -300,7 +300,7 @@ api.Use(circuitbreaker.New(circuitbreaker.Config{ The breaker trips (Closed → Open) when, within the current `WindowSize`, the total request count is at least `MinRequests` **and** `failures/total >= Threshold`. `MinRequests` stops a tiny sample (2 of 3 requests failing) from -tripping prematurely. Source: `celeris/middleware/circuitbreaker/circuitbreaker.go:193`. +tripping prematurely. Source: `celeris/middleware/circuitbreaker/circuitbreaker.go` (`Breaker.settle`). A handler that panics is a failure, with the same transition as a returned one; the panic then continues to your recovery middleware. Before celeris v1.6.0 a @@ -322,7 +322,7 @@ spent, so it answered `503` to every request from then on > Out-of-range values **panic** at construction: `Threshold` must be in `(0, 1]`, > `WindowSize >= 10ms`, `CooldownPeriod > 0`, `HalfOpenMax >= 1`, `MinRequests -> >= 1`. Source: `celeris/middleware/circuitbreaker/config.go:120`. +> >= 1`. Source: `celeris/middleware/circuitbreaker/config.go` (`Config.validate`). ### What counts as a failure: `IsError` @@ -380,7 +380,7 @@ s.GET("/internal/breaker", func(c *celeris.Context) error { ``` `Breaker` exposes `State() State`, `Counts() (total, failures int64)`, and -`Reset()`. Source: `celeris/middleware/circuitbreaker/circuitbreaker.go:54-74`. +`Reset()`. Source: `celeris/middleware/circuitbreaker/circuitbreaker.go` (`Breaker.State`). The sentinel for the open state is `circuitbreaker.ErrServiceUnavailable`, which aliases `celeris.ErrServiceUnavailable` (`503`) — the **same** sentinel `timeout` @@ -388,8 +388,8 @@ uses, so `errors.Is(err, celeris.ErrServiceUnavailable)` matches both. (The `overload` middleware also returns `503`, but it does so via `c.AbortWithStatus(503)` rather than this sentinel, and `ratelimit` returns its own `429` sentinel `ratelimit.ErrTooManyRequests`.) -Source: `celeris/middleware/circuitbreaker/config.go:78-81`, -`celeris/errors.go:50`. +Source: `celeris/middleware/circuitbreaker/config.go` (`ErrServiceUnavailable`), +`celeris/errors.go` (`ErrServiceUnavailable`). ## Timeouts @@ -402,7 +402,7 @@ The middleware wraps the request context with a deadline and lets the handler run. Your handler **cooperatively** observes the deadline by selecting on `c.Context().Done()` (or passing `c.Context()` to an `http.Client`, DB driver, etc.). If the handler overruns the deadline, the middleware returns the timeout -response. Source: `celeris/middleware/timeout/timeout.go:36`. +response. Source: `celeris/middleware/timeout/timeout.go` (`New`). ```go import "github.com/goceleris/celeris/middleware/timeout" @@ -439,7 +439,7 @@ s.Use(timeout.New(timeout.Config{ > **Preemptive mode has two hard constraints:** > 1. **Handlers must still honour `c.Context().Done()`.** Preemptive mode bounds > the client wait, but a handler that never returns keeps leaking a goroutine. -> Always select on cancellation. Source: `celeris/middleware/timeout/config.go:38`. +> Always select on cancellation. Source: `celeris/middleware/timeout/config.go` (`Config.Preemptive`). > 2. **It is incompatible with streaming.** Buffered mode captures the whole > response in memory, defeating `StreamWriter` and risking OOM on large > payloads. Use the non-preemptive default for SSE/streaming endpoints. See @@ -482,10 +482,10 @@ s.Use(timeout.New(timeout.Config{ | `Skip` / `SkipPaths` | `func` / `[]string` | — | Bypass certain requests/paths | > `New` **panics** if `Timeout <= 0` and `TimeoutFunc` is nil — you must give it -> *some* deadline. Source: `celeris/middleware/timeout/config.go:80`. The +> *some* deadline. Source: `celeris/middleware/timeout/config.go` (`applyDefaults`). The > `ErrorHandler` receives `context.DeadlineExceeded` for a deadline timeout, the > matched `TimeoutErrors` entry for a semantic timeout, or a panic-wrapped error -> for a recovered panic. Source: `celeris/middleware/timeout/config.go:27`. +> for a recovered panic. Source: `celeris/middleware/timeout/config.go` (`Config.ErrorHandler`). ## Adaptive overload shedding @@ -493,7 +493,7 @@ Rate limits and circuit breakers are *static* rules. The `overload` middleware i *adaptive*: a background goroutine samples real server pressure — CPU utilization, in-flight request depth, and tail latency — and walks a five-stage ladder, degrading more aggressively as pressure rises and recovering (with -hysteresis) as it falls. Source: `celeris/middleware/overload/overload.go:1-23`. +hysteresis) as it falls. Source: `celeris/middleware/overload/overload.go`. | Stage | CPU default | Behaviour | | ----- | ----------- | --------- | @@ -504,7 +504,7 @@ hysteresis) as it falls. Source: `celeris/middleware/overload/overload.go:1-23`. | **Backpressure** | ≥ 0.90 | Low-priority requests get `503` (`BackpressureStatus`); others sleep `BackpressureDelay` (default 50ms) then pass; exempt pass | | **Reject** | ≥ 0.95 | All non-exempt requests get `503` + `Retry-After` | -Source: `celeris/middleware/overload/config.go:16-24` and `config.go:177`. +Source: `celeris/middleware/overload/config.go` (`Stage`, `defaultThresholds`). ### `CollectorProvider` is required @@ -512,7 +512,7 @@ The middleware reads CPU from an `observe.Collector`, and the collector must hav a CPU monitor attached. `New` **panics** if `CollectorProvider` is nil. The provider is a *function* (not a value) because the server only creates its collector at `Start`, so you pass a closure that fetches it lazily. -Source: `celeris/middleware/overload/overload.go:103-105`. +Source: `celeris/middleware/overload/overload.go` (`NewWithController`). ```go import ( @@ -535,12 +535,12 @@ s.Use(overload.New(overload.Config{ `s.Collector()` is created eagerly in `New`, so it is **non-nil before `Start`** — the `if col := s.Collector(); col != nil` snippet above can attach the CPU monitor at setup time. It returns nil only when `Config.DisableMetrics` is true. -Source: `celeris/server.go:863-868`. +Source: `celeris/server.go` (`Server.Collector`). `observe.NewCPUMonitor()` returns a platform-appropriate monitor (Linux reads `/proc/stat`; others use `runtime/metrics`) and a non-nil `error`, so check it -before calling `SetCPUMonitor`. Source: `celeris/observe/cpumon_linux.go:10`, -`cpumon_other.go:9`, `celeris/observe/collector.go:124` (`SetCPUMonitor`), -`collector.go:71` (`CPUMonitor`). For the full metrics surface see +before calling `SetCPUMonitor`. Source: `celeris/observe/cpumon_linux.go` (`NewCPUMonitor`), +`celeris/observe/cpumon_other.go` (`NewCPUMonitor`), `celeris/observe/collector.go` (`SetCPUMonitor`), +`celeris/observe/collector.go` (`CPUMonitor`). For the full metrics surface see [Configuration](/docs/configuration). ### Depth and latency signals @@ -550,7 +550,7 @@ upstream. CPU stays low while requests queue up. Add `DepthThresholds` (absolute in-flight counts) and/or `LatencyThresholds` (EMA tail-latency targets) — whichever signal produces the *highest* stage wins. A depth/latency threshold can escalate the stage **beyond** the CPU-driven one, never below it. -Source: `celeris/middleware/overload/overload.go:137-155`, `overload.go:235-245`. +Source: `celeris/middleware/overload/overload.go` (`NewWithController`), `celeris/middleware/overload/overload.go` (`run`). A request whose handler panics gives its in-flight count back too; before celeris v1.6.0 each panic leaked one, until the depth thresholds rejected every request ([celeris#921](https://github.com/goceleris/celeris/issues/921)). @@ -574,7 +574,7 @@ s.Use(overload.New(overload.Config{ Zero-valued threshold fields disable that signal for that stage, so you opt into exactly the signals you want. `DepthThresholds` guidance from the source: try `Reorder = 2×workers`, `Backpressure = 4×workers`, `Reject = 8×workers`. -Source: `celeris/middleware/overload/config.go:62-66`. +Source: `celeris/middleware/overload/config.go` (`DepthThresholds`). ### Priority: deciding *who* to shed @@ -583,7 +583,7 @@ expendable. `PriorityFunc` classifies each request (higher = more important); requests scoring **below** `PriorityThreshold` (default `0`) are the ones rejected at Reorder and delayed/rejected at Backpressure. Without a `PriorityFunc` all requests share priority 0, so Reorder passes everything and Backpressure -delays everything. Source: `celeris/middleware/overload/config.go:128-137`. +delays everything. Source: `celeris/middleware/overload/config.go` (`Config`). ```go s.Use(overload.New(overload.Config{ @@ -656,13 +656,13 @@ s.GET("/internal/overload", func(c *celeris.Context) error { // ctrl.Stop() halts the sampler (the stage then freezes at its last value). ``` -Source: `celeris/middleware/overload/overload.go:48-85`. +Source: `celeris/middleware/overload/overload.go` (`Controller.Stage`). ## Body limits `bodylimit.New` rejects oversized request bodies before your handler runs. Set a size in bytes with `MaxBytes`, or with a human-readable `Limit` string (which -takes precedence). Source: `celeris/middleware/bodylimit/config.go:11`. +takes precedence). Source: `celeris/middleware/bodylimit/config.go` (`Config.Limit`). ```go import "github.com/goceleris/celeris/middleware/bodylimit" @@ -675,12 +675,12 @@ s.Use(bodylimit.New(bodylimit.Config{Limit: "10MB"})) // 413 if larger suffixes — including the plain `KB`/`MB`/`GB` forms — are interpreted as powers of 1024, so `"10MB"` means 10 × 1024 × 1024 bytes. An invalid `Limit` string **panics** at construction. -Source: `celeris/middleware/bodylimit/config.go:83`. +Source: `celeris/middleware/bodylimit/config.go` (`parseSize`). The middleware checks in two phases: first the `Content-Length` header (fast reject), then the actual buffered body length (catches a lying or absent `Content-Length`). Bodyless methods (`GET`, `HEAD`, `DELETE`, `OPTIONS`, `TRACE`, -`CONNECT`) are auto-skipped. Source: `celeris/middleware/bodylimit/bodylimit.go:53-82`. +`CONNECT`) are auto-skipped. Source: `celeris/middleware/bodylimit/bodylimit.go` (`New`). ### `ContentLengthRequired` @@ -692,8 +692,8 @@ reject), then the actual buffered body length (catches a lying or absent > too for a route that accepts larger uploads. To force clients to > declare body size up front, set `ContentLengthRequired: true`; requests without > a `Content-Length` header are then rejected with `411 Length Required` before -> the body check. Source: `celeris/middleware/bodylimit/bodylimit.go:12-23`, -> `config.go:31`. +> the body check. Source: `celeris/middleware/bodylimit/bodylimit.go` (`New`), +> `celeris/middleware/bodylimit/config.go` (`Config.ContentLengthRequired`). ```go s.Use(bodylimit.New(bodylimit.Config{ @@ -718,7 +718,7 @@ s.Use(bodylimit.New(bodylimit.Config{ The sentinels are `bodylimit.ErrBodyTooLarge` (`413`) and `bodylimit.ErrLengthRequired` (`411`). -Source: `celeris/middleware/bodylimit/bodylimit.go:5-10`. +Source: `celeris/middleware/bodylimit/bodylimit.go` (`ErrBodyTooLarge`, `ErrLengthRequired`). ## Request coalescing & idempotency @@ -730,7 +730,7 @@ When many identical requests arrive at once (a cache stampede, a dashboard that N clients all refresh on the same tick), `singleflight` lets the **first** request run the handler and serves every concurrent duplicate a *copy* of that one response. Coalesced responses carry an `X-Singleflight: HIT` header. -Source: `celeris/middleware/singleflight/singleflight.go:102-149`, `singleflight.go:143`. +Source: `celeris/middleware/singleflight/singleflight.go` (`New`). ```go import "github.com/goceleris/celeris/middleware/singleflight" @@ -745,7 +745,7 @@ s.GET("/report/:id", singleflight.New(), buildExpensiveReport) > the auth and cookie components are what stop one user's response from being > served to another. **If you supply your own `KeyFunc`, you MUST incorporate > user identity** for any endpoint that returns user-specific data, or you will -> leak data across users. Source: `celeris/middleware/singleflight/config.go:19-38`. +> leak data across users. Source: `celeris/middleware/singleflight/config.go` (`Config.KeyFunc`). ```go s.GET("/me/feed", singleflight.New(singleflight.Config{ @@ -780,8 +780,8 @@ for XML never takes a leader's JSON. unique key with a write request, and if it has to retry (network blip, timeout), the server *replays the original response* instead of performing the operation twice. By default it applies to `POST`, `PUT`, `PATCH`, `DELETE`; other methods -pass through. Source: `celeris/middleware/idempotency/idempotency.go:1-19`, -`config.go:37-39`. +pass through. Source: `celeris/middleware/idempotency/idempotency.go`, +`celeris/middleware/idempotency/config.go` (`Config.Methods`). ```go import "github.com/goceleris/celeris/middleware/idempotency" @@ -800,7 +800,7 @@ How it behaves for a given key: - **Handler crashed mid-flight** — the lock expires after `LockTimeout` (default 30s) so the next request can retry. -Source: `celeris/middleware/idempotency/idempotency.go:71-212`. +Source: `celeris/middleware/idempotency/idempotency.go` (`New`). #### The store needs `SetNX` @@ -810,9 +810,9 @@ interfaces are combined in the `idempotency.KVStore` type, which is the static type of `Config.Store`, so a backend that lacks `SetNX` simply **won't compile** when you assign it. The default in-memory store satisfies `KVStore` out of the box; a Redis/Postgres store works too as long as it implements both interfaces. -Source: `celeris/middleware/idempotency/config.go:15-26`. When `Store` is left +Source: `celeris/middleware/idempotency/config.go` (`KVStore`). When `Store` is left nil, `New` installs the in-memory store. Source: -`celeris/middleware/idempotency/idempotency.go:48-50`. +`celeris/middleware/idempotency/idempotency.go` (`New`). ```go s.Use(idempotency.New(idempotency.Config{ @@ -828,8 +828,8 @@ s.Use(idempotency.New(idempotency.Config{ A client that reuses an idempotency key with a *different* body is almost always a bug. Set `BodyHash: true` to store a SHA-256 of the request body alongside the response; on replay, a body mismatch returns `422 Unprocessable Entity`. -Source: `celeris/middleware/idempotency/config.go:50-53`, -`idempotency.go:105-111`. +Source: `celeris/middleware/idempotency/config.go` (`Config.BodyHash`), +`celeris/middleware/idempotency/idempotency.go` (`New`). ```go s.Use(idempotency.New(idempotency.Config{BodyHash: true})) @@ -837,10 +837,10 @@ s.Use(idempotency.New(idempotency.Config{BodyHash: true})) With `BodyHash` enabled, a request whose body exceeds `MaxBodyBytes` (default 1 MiB) is rejected with `413` before hashing, since the hash can't be computed -over a truncated body. Source: `celeris/middleware/idempotency/idempotency.go:90-96`. +over a truncated body. Source: `celeris/middleware/idempotency/idempotency.go` (`New`). On the leader path, a *response* larger than `MaxBodyBytes` is still served, but it is not cached — the lock is released and later retries re-run the handler -rather than replaying. Source: `celeris/middleware/idempotency/idempotency.go:172-182`. +rather than replaying. Source: `celeris/middleware/idempotency/idempotency.go` (`New`). | Field | Type | Default | Purpose | | ----- | ---- | ------- | ------- | @@ -857,7 +857,7 @@ rather than replaying. Source: `celeris/middleware/idempotency/idempotency.go:17 > A missing key header just passes the request through — idempotency is opt-in > per request. An invalid key (non-printable, or longer than `MaxKeyLength`) -> returns `400`. Source: `celeris/middleware/idempotency/idempotency.go:78-84`. +> returns `400`. Source: `celeris/middleware/idempotency/idempotency.go` (`New`). ### When to use which diff --git a/src/content/docs/websocket.md b/src/content/docs/websocket.md index 9cb2cf5..b96fed9 100644 --- a/src/content/docs/websocket.md +++ b/src/content/docs/websocket.md @@ -25,7 +25,7 @@ import "github.com/goceleris/celeris/middleware/websocket" > WebSocket needs HTTP/1.1 connection takeover. HTTP/2 multiplexes streams over a > single TCP connection, so it can't be hijacked — the middleware returns > **426 Upgrade Required** for HTTP/2 upgrade requests -> (`celeris/middleware/websocket/websocket.go:69`). +> (`celeris/middleware/websocket/websocket.go` (`New`)). ## Echo-server quickstart @@ -64,13 +64,13 @@ func main() { That's a complete RFC 6455 echo server. `Handler` is the only required field; `websocket.New` **panics** at registration if it is `nil` -(`celeris/middleware/websocket/config.go:170`). +(`celeris/middleware/websocket/config.go` (`Config.validate`)). A few rules to internalise from the start: - The middleware is a normal `HandlerFunc`. **Non-WebSocket requests are passed through** to the next handler, so you can stack it with other middleware on the - same route (`celeris/middleware/websocket/websocket.go:64`). + same route (`celeris/middleware/websocket/websocket.go` (`New`)). - WebSocket routes are **async by construction** — registering `s.GET("/ws", websocket.New(cfg))` is all you need. You do **not** (and should not bother to) call `.Async()`: it would have no effect here. Note that @@ -83,7 +83,7 @@ A few rules to internalise from the start: or its group. See [Routing](/docs/routing). - When `Handler` returns, Celeris sends a normal close frame (code `1000`) if you haven't already and closes the connection - (`celeris/middleware/websocket/websocket.go:137`). + (`celeris/middleware/websocket/websocket.go` (`New`)). ### Reading request data inside the handler @@ -107,14 +107,14 @@ s.GET("/ws/:room", websocket.New(websocket.Config{ ## The `Conn` API -`*websocket.Conn` is the connection handle (`celeris/middleware/websocket/conn.go:38`). +`*websocket.Conn` is the connection handle (`celeris/middleware/websocket/conn.go` (`Conn`)). All write methods (`WriteMessage`, `WriteText`, `WriteBinary`, `WriteJSON`, `WritePing`, `WriteControl`) are internally serialised behind a single write lock, so calling them from several goroutines concurrently is safe — each complete message arrives intact. Reading, however, is single-goroutine: have at most **one** goroutine in `ReadMessage`/`ReadMessageReuse`/`NextReader` at a time. The common pattern is one read loop plus any number of writer goroutines -(`celeris/middleware/websocket/doc.go:30-32`). +(`celeris/middleware/websocket/doc.go`). ### Reading messages @@ -128,7 +128,7 @@ pattern is one read loop plus any number of writer goroutines another goroutine, store it in a map, whatever. `ReadMessageReuse` hands back an internal buffer that is overwritten on the next read; use it for echo servers and forwarding proxies where you process the message immediately and don't retain it -(`celeris/middleware/websocket/conn.go:394`, `:411`). +(`celeris/middleware/websocket/conn.go` (`Conn.ReadMessage`, `Conn.ReadMessageReuse`)). ```go // Owned copy — safe to keep: @@ -145,7 +145,7 @@ if err := c.ReadJSON(&cmd); err != nil { ``` `MessageType` is one of `websocket.TextMessage` or `websocket.BinaryMessage` -(`celeris/middleware/websocket/conn.go:29-33`). Control frames (ping/pong/close) +(`celeris/middleware/websocket/conn.go` (`TextMessage`)). Control frames (ping/pong/close) are handled transparently by the read loop and never surface from `ReadMessage`/`ReadMessageReuse`. @@ -170,7 +170,7 @@ c.WritePing(nil) If compression has been negotiated and a data payload is at least the compression threshold, `WriteMessage` compresses it transparently — and only keeps the compressed form if it actually came out smaller -(`celeris/middleware/websocket/conn.go:591`). +(`celeris/middleware/websocket/conn.go` (`Conn.WriteMessage`)). `WriteControl` is the right tool for pings/pongs/close from a separate goroutine under load: it acquires the write lock **with a deadline** so a stalled large @@ -179,7 +179,7 @@ if it can't get the lock in time. Its `messageType` parameter is a plain `int` that must hold a control opcode (`websocket.OpClose`, `websocket.OpPing`, `websocket.OpPong`) — convert with `int(...)` because those constants are typed `websocket.Opcode` — and `data` must be ≤ 125 bytes -(`celeris/middleware/websocket/conn.go:718`). +(`celeris/middleware/websocket/conn.go` (`Conn.WriteControl`)). ```go err := c.WriteControl( @@ -196,7 +196,7 @@ if err == websocket.ErrWriteTimeout { Override the default control-frame behaviour with the setter methods. **Set them before you start the read loop** — they aren't safe to swap mid-flight -(`celeris/middleware/websocket/conn.go:992-1001`). +(`celeris/middleware/websocket/conn.go` (`Conn.SetPingHandler`)). | Method | Default behaviour | | -------------------------------------------------------- | ----------------------------------------------------------- | @@ -259,7 +259,7 @@ c.GracefulClose(websocket.CloseNormalClosure, "bye") You usually don't need to call either — returning from `Handler` triggers an automatic close-frame send and `Close`. Use `GracefulClose` when you want a clean -RFC 6455 closing handshake before exiting (`celeris/middleware/websocket/conn.go:799`). +RFC 6455 closing handshake before exiting (`celeris/middleware/websocket/conn.go` (`Conn.GracefulClose`)). ### Connection metadata @@ -279,7 +279,7 @@ RFC 6455 closing handshake before exiting (`celeris/middleware/websocket/conn.go `Locals`/`SetLocals` give you a concurrency-safe per-connection bag — handy for stashing an authenticated user ID at connect time and reading it during -broadcasts (`celeris/middleware/websocket/conn.go:922-939`). +broadcasts (`celeris/middleware/websocket/conn.go` (`Conn.Locals`)). ```go Handler: func(c *websocket.Conn) { @@ -300,7 +300,7 @@ Handler: func(c *websocket.Conn) { `SetReadDeadline`/`SetWriteDeadline` delegate to the underlying `net.Conn`, which only exists on the std (hijack) path; on native engines they are no-ops -(`celeris/middleware/websocket/conn.go:976`, `:985`). For portable idle +(`celeris/middleware/websocket/conn.go` (`Conn.SetReadDeadline`, `Conn.SetWriteDeadline`)). For portable idle enforcement use [`Config.IdleTimeout`](#config). ### Runtime compression control @@ -311,7 +311,7 @@ If compression was negotiated, you can toggle it per connection: connection (only meaningful after negotiation). - `SetCompressionLevel(level int) error` — set the flate level for subsequent writes; valid range `-2`…`9`, else returns an error - (`celeris/middleware/websocket/conn.go:1015-1027`). + (`celeris/middleware/websocket/conn.go` (`Conn.EnableWriteCompression`, `Conn.SetCompressionLevel`)). ### Streaming large messages @@ -344,12 +344,12 @@ _, _ = io.Copy(dst, r) _ = mt ``` -Rules from the source (`celeris/middleware/websocket/writer.go:21-48`, -`celeris/middleware/websocket/reader.go:70-79`): +Rules from the source (`celeris/middleware/websocket/writer.go` (`Conn.NextWriter`), +`celeris/middleware/websocket/reader.go` (`Conn.NextReader`)): - **Only one writer may be active at a time.** Calling `WriteMessage`, `WriteText`, `WriteBinary`, or `WriteJSON` while a `NextWriter` is open returns - an error (`celeris/middleware/websocket/conn.go:595`). Control frames + an error (`celeris/middleware/websocket/conn.go` (`Conn.WriteMessage`)). Control frames (ping/pong/close via `WritePing`/`WriteControl`) *can* still be sent concurrently. - **You must call `Close()` on the writer** to send the final frame; until then @@ -361,7 +361,7 @@ Rules from the source (`celeris/middleware/websocket/writer.go:21-48`, ## Config -`websocket.Config` (`celeris/middleware/websocket/config.go:46`) is passed to +`websocket.Config` (`celeris/middleware/websocket/config.go` (`Config`)) is passed to `websocket.New`. Only `Handler` is required. | Field | Type | Default | Purpose | @@ -386,7 +386,7 @@ Rules from the source (`celeris/middleware/websocket/writer.go:21-48`, | `OnDisconnect` | `func(*Conn)` | `nil` | Runs after `Handler` returns. | | `WriteBufferPool` | `BufferPool` | `nil` | Pool write buffers across connections (std/hijack path only). | -Defaults are applied in `applyDefaults` (`celeris/middleware/websocket/config.go:141`). +Defaults are applied in `applyDefaults` (`celeris/middleware/websocket/config.go` (`applyDefaults`)). Note `CompressionLevel` and `CompressionThreshold` defaults are only filled in when `EnableCompression` is true. @@ -430,7 +430,7 @@ websocket.New(websocket.Config{ }) ``` -Compression-level constants (`celeris/middleware/websocket/compress.go:11-19`): +Compression-level constants (`celeris/middleware/websocket/compress.go` (`CompressionLevelDefault`)): | Constant | Value | Meaning | | ------------------------------------- | ----- | -------------------- | @@ -446,24 +446,24 @@ the engine's event loop and your handler goroutine. When it fills past `BackpressureHighPct` of `MaxBackpressureBuffer`, the engine **pauses** inbound delivery for that connection, which closes the kernel's TCP receive window and slows the peer at the network level; when it drains below `BackpressureLowPct`, -delivery resumes (`celeris/middleware/websocket/config.go:112-129`). This is fully +delivery resumes (`celeris/middleware/websocket/config.go` (`Config`)). This is fully automatic — the defaults (256 / 75% / 25%) suit most workloads. You can observe health with `Conn.BackpressureDropped()`, which should always be `0` in normal operation. A non-zero value means the engine pause/resume mechanism isn't keeping up. It always returns `0` on the std (hijack) path, where the kernel -TCP stack handles backpressure directly (`celeris/middleware/websocket/conn.go:887`). +TCP stack handles backpressure directly (`celeris/middleware/websocket/conn.go` (`Conn.BackpressureDropped`)). ### Write-buffer pooling (std/hijack path) `WriteBufferPool` lets you share `*bufio.Writer` instances across connections to cut per-connection memory for many idle sockets. It is **only consulted on the std (hijack) path** — native engines pool their own write buffer internally and -ignore this setting (`celeris/middleware/websocket/conn.go:206`, -`celeris/middleware/websocket/websocket.go:319`). Implement the `BufferPool` +ignore this setting (`celeris/middleware/websocket/conn.go` (`Conn.getWriter`), +`celeris/middleware/websocket/websocket.go` (`setupConn`)). Implement the `BufferPool` interface (`Get(dst io.Writer) *bufio.Writer` and `Put(*bufio.Writer)`); the typical implementation wraps a `sync.Pool` and `Reset(dst)`s the writer on -borrow (`celeris/middleware/websocket/config.go:28`). +borrow (`celeris/middleware/websocket/config.go` (`BufferPool`)). ## Broadcasting to many connections (Hub) @@ -472,7 +472,7 @@ Real-time apps usually need to push one message to *every* connected client abstraction: register connections, broadcast to all (or a filtered subset), unregister on disconnect. It encodes each frame **once** per broadcast and reuses it across every connection, so the wire-encoding cost is O(1) regardless of -subscriber count (`celeris/middleware/websocket/hub.go:56`). +subscriber count (`celeris/middleware/websocket/hub.go` (`Hub`, `Hub.Broadcast`)). ```go hub := websocket.NewHub(websocket.HubConfig{}) @@ -494,7 +494,7 @@ hub.Broadcast(websocket.TextMessage, []byte(`{"type":"tick"}`)) ``` `Register` returns an unregister function you **must** defer; it is safe to call -twice and a no-op after `Hub.Close` (`celeris/middleware/websocket/hub.go:92`). +twice and a no-op after `Hub.Close` (`celeris/middleware/websocket/hub.go` (`Hub.Register`)). | Method | Returns / behaviour | | --------------------------------------------------------------------- | ------------------------------------------------------------------------- | @@ -513,7 +513,7 @@ broadcasts to every registered conn unfiltered. ### Room / channel routing Use `BroadcastFilter` with a predicate over `Conn.Locals` to target a subset -without maintaining a second hub (`celeris/middleware/websocket/hub.go:153`): +without maintaining a second hub (`celeris/middleware/websocket/hub.go` (`Hub.BroadcastFilter`)): ```go // On connect, tag the conn with its room: @@ -529,7 +529,7 @@ hub.BroadcastFilter(websocket.TextMessage, payload, func(c *websocket.Conn) bool By default a conn whose write fails during a broadcast is unregistered **and closed**. Override with `HubConfig.OnSlowConn`, returning a `HubPolicy` -(`celeris/middleware/websocket/hub.go:10-37`): +(`celeris/middleware/websocket/hub.go` (`HubConfig.OnSlowConn`)): | Policy | Effect | | ------------------ | -------------------------------------------------------------- | @@ -547,16 +547,16 @@ hub := websocket.NewHub(websocket.HubConfig{ `Hub.Close` waits for every in-flight broadcast to finish before tearing down conns, so a shutdown path that synchronises on `Close` cannot race a still -fanning-out message (`celeris/middleware/websocket/hub.go:315`). +fanning-out message (`celeris/middleware/websocket/hub.go` (`Hub.Close`)). ### Bounding broadcast concurrency A `Broadcast` fans out to every conn concurrently. `HubConfig.MaxConcurrency` caps how many of those per-conn writes run at once via a semaphore, keeping goroutine pressure bounded on very large hubs -(`celeris/middleware/websocket/hub.go:46`). Leave it `0` (the default) to use +(`celeris/middleware/websocket/hub.go` (`HubConfig.MaxConcurrency`)). Leave it `0` (the default) to use `DefaultHubConcurrency()` — `runtime.GOMAXPROCS(0) * 4` -(`celeris/middleware/websocket/hub.go:54`): +(`celeris/middleware/websocket/hub.go` (`DefaultHubConcurrency`)): ```go hub := websocket.NewHub(websocket.HubConfig{ @@ -569,7 +569,7 @@ hub := websocket.NewHub(websocket.HubConfig{ When you publish the same payload repeatedly, build a `PreparedMessage` once and reuse it. The frame is encoded a single time (with separate cached uncompressed/compressed variants) and shared across every send -(`celeris/middleware/websocket/prepared.go:9-54`): +(`celeris/middleware/websocket/prepared.go` (`NewPreparedMessage`)): ```go pm, err := websocket.NewPreparedMessage(websocket.TextMessage, payload) @@ -584,13 +584,13 @@ _ = conn.WritePreparedMessage(pm) `NewPreparedMessage` rejects control opcodes (ping/pong/close) and returns `websocket.ErrInvalidPreparedOpcode` — control frames have RFC 6455 §5.5 size constraints the cache-and-reuse model can't honour; use `WriteControl` -per-connection for those (`celeris/middleware/websocket/prepared.go:42`). +per-connection for those (`celeris/middleware/websocket/prepared.go` (`NewPreparedMessage`)). ## Origin checking By default the middleware enforces a **same-origin** policy: the request's `Origin` header host must match the `Host` header -(`celeris/middleware/websocket/websocket.go:84`). This protects against +(`celeris/middleware/websocket/websocket.go` (`New`)). This protects against cross-site WebSocket hijacking (CSWSH) from a malicious page. The exact default behaviour: @@ -624,7 +624,7 @@ websocket.New(websocket.Config{ When `CheckOrigin` is set, the built-in same-origin logic is bypassed entirely — your function is the sole gate. A rejected origin yields **403** with body -`websocket: origin not allowed` (`celeris/middleware/websocket/websocket.go:82`). +`websocket: origin not allowed` (`celeris/middleware/websocket/websocket.go` (`New`)). > **Don't reflexively allow all origins.** `func(*celeris.Context) bool { return > true }` disables the browser's only built-in defence against another site @@ -634,7 +634,7 @@ your function is the sole gate. A rejected origin yields **403** with body ## Close handling When the peer sends a close frame, the read loop returns a `*CloseError` from the -default close handler (`celeris/middleware/websocket/conn.go:1029`): +default close handler (`celeris/middleware/websocket/conn.go` (`Conn.SetCloseHandler`, `CloseError`)): ```go type CloseError struct { @@ -668,7 +668,7 @@ for { ``` The standard RFC 6455 close codes are exported as constants -(`celeris/middleware/websocket/opcode.go:28-43`): +(`celeris/middleware/websocket/opcode.go` (`CloseNormalClosure`)): | Constant | Code | Meaning | | --------------------------------- | ------ | ----------------------------- | @@ -700,7 +700,7 @@ _ = c.WriteControl( ## Engine behavior The same `Handler` runs on every engine, but the I/O underneath differs in ways -worth knowing (`celeris/middleware/websocket/doc.go:41-44`): +worth knowing (`celeris/middleware/websocket/doc.go`): | Aspect | Native engines (epoll, io_uring) | std engine (hijack) | | --------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------ | @@ -716,7 +716,7 @@ The practical takeaways: 1. **Don't rely on `NetConn()` or `SetReadDeadline`/`SetWriteDeadline` for portable code.** They only do something on the std engine. For an idle timeout that works everywhere, set `Config.IdleTimeout` — both paths converge - to the same observable behaviour (`celeris/middleware/websocket/config.go:104-110`). + to the same observable behaviour (`celeris/middleware/websocket/config.go` (`Config.IdleTimeout`)). 2. **Backpressure tuning only matters on native engines.** On the std path the relevant fields are simply ignored. @@ -732,10 +732,10 @@ applications never need them. | `c.UpgradeWebSocket(delivery func([]byte)) bool` | Engine-integrated upgrade; `false` if the engine doesn't support it. | `Hijack` hands you the raw `net.Conn` and makes you responsible for the -handshake, framing, and closing it (`celeris/context_response.go:1289-1321`). +handshake, framing, and closing it (`celeris/context_response.go` (`Context.Hijack`)). `UpgradeWebSocket` installs a data-delivery callback for the native engine path and returns `false` on the std engine, where you fall back to `Hijack` -(`celeris/context_response.go:1323-1347`). Driving these correctly means +(`celeris/context_response.go` (`Context.UpgradeWebSocket`)). Driving these correctly means re-implementing RFC 6455 yourself — the `websocket` middleware exists precisely so you don't have to. For other long-lived response patterns built on the same primitives, see [Streaming responses](/docs/streaming). @@ -746,7 +746,7 @@ primitives, see [Streaming responses](/docs/streaming). loop (and any keepalive ticker) inside the handler and only return when you want to disconnect. - **`Handler: nil` panics at startup.** It's the one required field - (`celeris/middleware/websocket/config.go:170`). + (`celeris/middleware/websocket/config.go` (`Config.validate`)). - **Never `.Sync()` a WebSocket route.** WebSocket handlers are async by construction; forcing them inline breaks them (see [Routing](/docs/routing)). - **`ReadMessageReuse` slices are transient.** The returned buffer is overwritten @@ -757,19 +757,19 @@ primitives, see [Streaming responses](/docs/streaming). - **`NetConn()` / `SetReadDeadline` are `nil` / no-ops on native engines.** Use `Config.IdleTimeout` for a portable idle timeout. - **`WriteControl` data is capped at 125 bytes** and the opcode must be a control - opcode, or it returns an error (`celeris/middleware/websocket/conn.go:718`). + opcode, or it returns an error (`celeris/middleware/websocket/conn.go` (`Conn.WriteControl`)). ## FAQ **Why am I getting a 426 response?** The request arrived over HTTP/2, which can't be hijacked. WebSocket requires -HTTP/1.1 (`celeris/middleware/websocket/websocket.go:69`). +HTTP/1.1 (`celeris/middleware/websocket/websocket.go` (`New`)). **Why does my upgrade return 403?** The default same-origin check rejected it — either the `Origin` host didn't match `Host`, or `Origin` was missing on an `https` request. Set `Config.CheckOrigin` for non-browser clients or cross-origin setups -(`celeris/middleware/websocket/websocket.go:84`). +(`celeris/middleware/websocket/websocket.go` (`New`)). **Can I read and write from different goroutines?** Yes. One goroutine may read while others write — all write methods are internally @@ -779,11 +779,11 @@ message at once. **How do I cap message size?** Set `Config.ReadLimit` (or call `Conn.SetReadLimit` at runtime). The default is 64MB; oversize messages close the connection with code `1009` -(`celeris/middleware/websocket/config.go:74`). +(`celeris/middleware/websocket/config.go` (`Config.ReadLimit`)). **How do I time out idle connections on every engine?** Set `Config.IdleTimeout`. Unlike `SetReadDeadline`, it's honoured on both the -native and std paths (`celeris/middleware/websocket/config.go:104`). +native and std paths (`celeris/middleware/websocket/config.go` (`Config.IdleTimeout`)). **Does the middleware send a close frame for me?** Yes — when `Handler` returns, Celeris sends a `1000` close frame (if you haven't