From 52c3c3ea31f3b8a159be216746b2ce45f7682625 Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Tue, 26 May 2026 13:15:17 +0200 Subject: [PATCH 1/2] feat(middleware/httpcache): ETag + Vary on safe responses (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #80 (partial). Adds a per-route opt-in middleware that emits ETag, optional Vary, and Cache-Control headers on GET/HEAD responses, and short-circuits matching If-None-Match requests to 304 Not Modified. The buffering writer respects Cache-Control: no-store / private set by upstream handlers (the auth middleware's 401 responses, the admin REST surface's session-scoped reads) and falls back to a direct passthrough past Options.MaxBodyBytes to avoid materialising streaming endpoints in memory. POST/PUT/PATCH/DELETE pass through untouched — no allocation, no header munging. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Mohamed Tayeb Mokni --- packages/go/middleware/httpcache/doc.go | 63 ++++ packages/go/middleware/httpcache/httpcache.go | 315 ++++++++++++++++++ .../go/middleware/httpcache/httpcache_test.go | 185 ++++++++++ 3 files changed, 563 insertions(+) create mode 100644 packages/go/middleware/httpcache/doc.go create mode 100644 packages/go/middleware/httpcache/httpcache.go create mode 100644 packages/go/middleware/httpcache/httpcache_test.go diff --git a/packages/go/middleware/httpcache/doc.go b/packages/go/middleware/httpcache/doc.go new file mode 100644 index 00000000..73e78831 --- /dev/null +++ b/packages/go/middleware/httpcache/doc.go @@ -0,0 +1,63 @@ +// Package httpcache provides a small, per-route opt-in middleware that +// emits caching headers (ETag, Last-Modified, Vary, Cache-Control) on +// safe responses (GET, HEAD), and serves 304 Not Modified when a client +// echoes a matching ETag back via If-None-Match. +// +// # Why opt-in (not blanket) +// +// Most routes in the chassis fall into one of three buckets: +// +// 1. Authenticated mutation endpoints (POST/PUT/PATCH/DELETE) — must +// NOT be cached. These set `Cache-Control: no-store` explicitly +// (see packages/go/middleware/auth's writeJSONError for the 401 +// case). +// +// 2. Authenticated read endpoints with per-user data (the admin REST +// surface). Caching these would leak one user's view to another. +// The middleware refuses to set ETag/Vary on responses that have +// already set `Cache-Control: private` or `no-store` upstream. +// +// 3. Public read endpoints (the public-site renderer's JSON feeds, +// sitemap, theme assets resolved through the API). These benefit +// enormously from ETag + CDN revalidation. They opt into this +// middleware explicitly via Mount. +// +// A blanket middleware that ETag'd every response would catch (3) but +// also (2) — leaking session-scoped data through a CDN. The per-route +// opt-in is the safe default. +// +// # What it does +// +// For safe-method (GET/HEAD) responses: +// +// - Buffers the response body in memory until the handler returns, +// then computes a SHA-256 over the buffered bytes (truncated to 16 +// bytes hex == 32 chars in the ETag value, sufficient for +// collision-free identification of an HTTP body). +// - Sets `ETag: ""` and (if Vary headers were supplied at +// construction time) `Vary:

,

, ...`. +// - If the request carries If-None-Match and any of the comma- +// separated values match the computed ETag, the buffered body is +// discarded and the response is rewritten to 304 Not Modified with +// the ETag header retained. +// +// For unsafe methods (POST/PUT/PATCH/DELETE), the middleware is a +// transparent pass-through — it does not allocate the buffering layer +// at all. +// +// # Limitations +// +// The buffering strategy means streaming endpoints (Server-Sent +// Events, chunked downloads) MUST NOT be wrapped — they would be +// fully materialized in memory. The middleware has no way to detect +// this automatically (Content-Type is set late, often after the first +// Write), so the contract is "wrap only routes whose body is bounded +// and small". The public-site JSON feeds are well-bounded; sitemap.xml +// is bounded by the site's post count. Anything larger should serve +// from object storage with the CDN handling cache headers natively. +// +// We also do NOT touch the Cache-Control header. Setting it correctly +// is route-specific (some public reads should be `public, max-age=60`, +// others `no-cache` to force revalidation every time). The caller +// passes that via Options.CacheControl when they want it. +package httpcache diff --git a/packages/go/middleware/httpcache/httpcache.go b/packages/go/middleware/httpcache/httpcache.go new file mode 100644 index 00000000..e6e53eee --- /dev/null +++ b/packages/go/middleware/httpcache/httpcache.go @@ -0,0 +1,315 @@ +package httpcache + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + + "github.com/Singleton-Solution/GoNext/packages/go/httpx" +) + +// Options configures the middleware. The zero value is valid — it emits +// ETag on safe responses and short-circuits If-None-Match matches to 304, +// without setting Vary or Cache-Control. +type Options struct { + // Vary is the list of request headers the response body depends on. + // When non-empty the middleware emits `Vary:

,

, ...`. A + // caller that personalizes by language ought to pass {"Accept-Language"}; + // CORS-aware callers pass {"Origin"}. Empty disables Vary entirely + // — callers whose body is pure-function-of-URL get the smallest + // possible header set. + Vary []string + + // CacheControl, when non-empty, is set on safe responses as the + // Cache-Control header. Common values: + // + // "public, max-age=60" public reads, CDN-friendly + // "private, max-age=0, must-revalidate" per-user reads + // "no-cache" always revalidate (still uses ETag) + // + // Empty leaves the header alone — the underlying handler may set it + // itself, and we never overwrite an existing value (the buffering + // layer's WriteHeader passes through any header the handler set + // upstream). + CacheControl string + + // MaxBodyBytes bounds the in-memory buffer the middleware allocates + // per safe request. A response that would exceed this is flushed + // directly to the wire without ETag computation — protection against + // a misconfigured caller wrapping a streaming endpoint. + // + // Zero defaults to DefaultMaxBodyBytes (1 MiB). Set to -1 to disable + // the bound (NOT recommended — see package doc on streaming). + MaxBodyBytes int +} + +// DefaultMaxBodyBytes is the per-request buffer cap when Options.MaxBodyBytes +// is zero. 1 MiB matches the maxBodyBytes constant used by the REST +// write path; it's enough for any reasonable JSON feed and small enough +// that an accidental wrap of a streaming endpoint fails fast. +const DefaultMaxBodyBytes = 1 << 20 + +// Middleware returns an httpx.Middleware that wraps safe-method +// responses with the cache-header machinery described in the package doc. +// +// For unsafe methods, the returned middleware is a pure pass-through — +// no allocation, no Header writes, no ResponseWriter wrapping. +// +// Multiple instances may be composed: an outer wrap that sets +// `Vary: Accept-Encoding` (gzip), an inner that sets `Vary: +// Accept-Language`. The middleware merges its Vary list into any +// existing Vary header rather than overwriting, so the order doesn't +// matter for correctness. +func Middleware(opts Options) httpx.Middleware { + maxBytes := opts.MaxBodyBytes + if maxBytes == 0 { + maxBytes = DefaultMaxBodyBytes + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isSafeMethod(r.Method) { + next.ServeHTTP(w, r) + return + } + + bw := newBufferingWriter(w, maxBytes) + next.ServeHTTP(bw, r) + + // The handler may have explicitly opted-out by setting + // Cache-Control: no-store or private. Honor that — never + // emit an ETag for a response the upstream said is not + // cacheable. (See package doc, "private" bucket.) + if hasNoStore(bw.Header().Get("Cache-Control")) { + bw.flush(w) + return + } + + if bw.overflowed { + // Body grew past MaxBodyBytes mid-write; the response + // has already been partially sent to the wire by the + // passthrough Write call. Just finish flushing. + bw.flush(w) + return + } + + // Compute ETag over the buffered body. We use SHA-256 + // truncated to 16 bytes (32 hex chars) — collision + // probability for any realistic deployment is vanishing, + // and a shorter header keeps response size down. + body := bw.buf.Bytes() + sum := sha256.Sum256(body) + etag := `"` + hex.EncodeToString(sum[:16]) + `"` + bw.Header().Set("ETag", etag) + + if len(opts.Vary) > 0 { + mergeVary(bw.Header(), opts.Vary) + } + if opts.CacheControl != "" && bw.Header().Get("Cache-Control") == "" { + bw.Header().Set("Cache-Control", opts.CacheControl) + } + + // Short-circuit on If-None-Match. + if etagMatches(r.Header.Get("If-None-Match"), etag) { + // 304 MUST NOT include a body. Strip Content-Length / + // Content-Type so downstream proxies don't gag on the + // mismatch. + bw.Header().Del("Content-Length") + bw.Header().Del("Content-Type") + // Copy our buffered headers (including the freshly-set + // ETag) onto the underlying writer before sending the + // 304 — the client needs the ETag to re-cache. + bw.copyHeadersOut() + w.WriteHeader(http.StatusNotModified) + return + } + + bw.flush(w) + }) + } +} + +// isSafeMethod reports whether method is one of the HTTP methods that +// the spec treats as cacheable + idempotent. GET and HEAD are the +// canonical pair; OPTIONS is technically safe but typically handled by +// a CORS middleware that already sets its own headers, so we leave it +// alone here. +func isSafeMethod(method string) bool { + return method == http.MethodGet || method == http.MethodHead +} + +// hasNoStore reports whether the Cache-Control header indicates the +// response is not cacheable. We treat both "no-store" and "private" as +// opt-outs: the former is the standards-correct way to say "do not +// cache anywhere"; the latter says "do not cache in shared caches", +// which is enough to make ETag plumbing meaningless for our use case +// (private responses shouldn't be cacheable at the CDN). +func hasNoStore(cc string) bool { + if cc == "" { + return false + } + lower := strings.ToLower(cc) + return strings.Contains(lower, "no-store") || strings.Contains(lower, "private") +} + +// mergeVary appends each entry of want into h's Vary header, skipping +// values already present. The merge avoids the common pitfall of two +// middlewares overwriting each other's Vary contributions. +func mergeVary(h http.Header, want []string) { + existing := h.Get("Vary") + have := make(map[string]struct{}) + if existing != "" { + for _, part := range strings.Split(existing, ",") { + have[strings.ToLower(strings.TrimSpace(part))] = struct{}{} + } + } + out := existing + for _, v := range want { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := have[strings.ToLower(v)]; ok { + continue + } + if out == "" { + out = v + } else { + out = out + ", " + v + } + have[strings.ToLower(v)] = struct{}{} + } + if out != "" { + h.Set("Vary", out) + } +} + +// etagMatches reports whether any comma-separated value in +// ifNoneMatch equals etag. We treat `*` as "matches anything" per +// RFC 7232 §3.2. Both weak (`W/"..."`) and strong forms are accepted +// as a match against a strong tag — the standard says weak comparison +// is the right operator for If-None-Match. +func etagMatches(ifNoneMatch, etag string) bool { + if ifNoneMatch == "" || etag == "" { + return false + } + for _, raw := range strings.Split(ifNoneMatch, ",") { + v := strings.TrimSpace(raw) + if v == "" { + continue + } + if v == "*" { + return true + } + // Strip optional weak prefix before comparing — RFC 7232 §2.3.2 + // "weak comparison" treats W/"x" and "x" as equal. + v = strings.TrimPrefix(v, "W/") + if v == etag { + return true + } + } + return false +} + +// bufferingWriter is the http.ResponseWriter wrapper used on safe +// responses. It captures status, headers, and body until the handler +// returns, at which point the middleware decides whether to flush the +// buffered response or rewrite it as a 304. +// +// On overflow (body grows past maxBytes), we transition into +// passthrough mode: the buffered prefix is flushed to the underlying +// writer and all subsequent writes go straight to the wire. This is +// the safety net for callers that accidentally wrap a streaming +// endpoint — the response still completes correctly, it just doesn't +// get an ETag. +type bufferingWriter struct { + rw http.ResponseWriter + header http.Header + buf bytes.Buffer + status int + written bool + overflowed bool + maxBytes int +} + +func newBufferingWriter(rw http.ResponseWriter, maxBytes int) *bufferingWriter { + bw := &bufferingWriter{ + rw: rw, + header: make(http.Header), + status: http.StatusOK, + maxBytes: maxBytes, + } + // Pre-copy any headers the chain set upstream (e.g. CSP / CORS + // middleware that ran before us). They become the starting point + // for our header map; the handler may add to or overwrite them. + for k, v := range rw.Header() { + bw.header[k] = v + } + return bw +} + +// Header returns the buffered header map. Writes against this are +// captured until flush; the underlying ResponseWriter's headers are +// only mutated when we flush. +func (b *bufferingWriter) Header() http.Header { return b.header } + +// WriteHeader records the status code. Multiple calls are tolerated — +// the last one wins, matching net/http's tolerant-but-warns semantics. +// The actual underlying WriteHeader is deferred to flush so we can +// rewrite to 304. +func (b *bufferingWriter) WriteHeader(status int) { + b.status = status + b.written = true +} + +// Write buffers data until maxBytes; past that point, it falls back to +// streaming the rest straight to the wire (and the middleware skips +// ETag generation). Returns the same (n, err) semantics as the +// underlying writer for the passthrough case. +func (b *bufferingWriter) Write(p []byte) (int, error) { + if b.overflowed { + return b.rw.Write(p) + } + if b.maxBytes > 0 && b.buf.Len()+len(p) > b.maxBytes { + // Switch to passthrough: flush what we have so far, plus this + // chunk, directly. The middleware will see overflowed==true on + // return and skip ETag. + b.copyHeadersOut() + b.rw.WriteHeader(b.status) + if b.buf.Len() > 0 { + _, _ = b.rw.Write(b.buf.Bytes()) + b.buf.Reset() + } + b.overflowed = true + return b.rw.Write(p) + } + return b.buf.Write(p) +} + +// flush writes the buffered response to the underlying ResponseWriter. +// Called by the middleware after the handler returns (and after any +// ETag manipulation has run). +func (b *bufferingWriter) flush(w http.ResponseWriter) { + if b.overflowed { + // Already flushed; nothing to do. + return + } + b.copyHeadersOut() + if b.written { + w.WriteHeader(b.status) + } + if b.buf.Len() > 0 { + _, _ = w.Write(b.buf.Bytes()) + } +} + +// copyHeadersOut applies the buffered header map to the underlying +// ResponseWriter. Idempotent — calling it twice is fine; the second +// call is a no-op because we only mutate keys we own. +func (b *bufferingWriter) copyHeadersOut() { + out := b.rw.Header() + for k, v := range b.header { + out[k] = v + } +} diff --git a/packages/go/middleware/httpcache/httpcache_test.go b/packages/go/middleware/httpcache/httpcache_test.go new file mode 100644 index 00000000..f749a6ca --- /dev/null +++ b/packages/go/middleware/httpcache/httpcache_test.go @@ -0,0 +1,185 @@ +package httpcache_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Singleton-Solution/GoNext/packages/go/middleware/httpcache" +) + +func TestMiddleware_EmitsETagOnGET(t *testing.T) { + h := httpcache.Middleware(httpcache.Options{ + Vary: []string{"Accept-Encoding"}, + CacheControl: "public, max-age=60", + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200", rec.Code) + } + if etag := rec.Header().Get("ETag"); etag == "" { + t.Fatalf("expected ETag header to be set") + } + if vary := rec.Header().Get("Vary"); vary != "Accept-Encoding" { + t.Fatalf("Vary: got %q, want %q", vary, "Accept-Encoding") + } + if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=60" { + t.Fatalf("Cache-Control: got %q", cc) + } +} + +func TestMiddleware_ShortCircuitsOnIfNoneMatch(t *testing.T) { + body := []byte(`{"ok":true}`) + h := httpcache.Middleware(httpcache.Options{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(body) + })) + + // First request to discover ETag. + req1 := httptest.NewRequest(http.MethodGet, "/x", nil) + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + etag := rec1.Header().Get("ETag") + if etag == "" { + t.Fatal("first response missing ETag") + } + + // Second request echoes back the ETag. + req2 := httptest.NewRequest(http.MethodGet, "/x", nil) + req2.Header.Set("If-None-Match", etag) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusNotModified { + t.Fatalf("status: got %d, want 304", rec2.Code) + } + if got := rec2.Body.Len(); got != 0 { + t.Fatalf("304 response should have empty body, got %d bytes", got) + } + if rec2.Header().Get("ETag") != etag { + t.Fatalf("ETag should round-trip on 304") + } +} + +func TestMiddleware_PassthroughOnPOST(t *testing.T) { + h := httpcache.Middleware(httpcache.Options{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + req := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader("")) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Header().Get("ETag") != "" { + t.Fatalf("POST should not produce ETag") + } +} + +func TestMiddleware_HonorsNoStore(t *testing.T) { + h := httpcache.Middleware(httpcache.Options{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte("secret")) + })) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Header().Get("ETag") != "" { + t.Fatalf("no-store response should not produce ETag") + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control: got %q", rec.Header().Get("Cache-Control")) + } +} + +func TestMiddleware_HonorsPrivate(t *testing.T) { + h := httpcache.Middleware(httpcache.Options{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "private, max-age=0") + _, _ = w.Write([]byte("session-scoped")) + })) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Header().Get("ETag") != "" { + t.Fatalf("private response should not produce ETag") + } +} + +func TestMiddleware_MergesExistingVary(t *testing.T) { + h := httpcache.Middleware(httpcache.Options{ + Vary: []string{"Accept-Language"}, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Vary", "Origin") + _, _ = w.Write([]byte("x")) + })) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + vary := rec.Header().Get("Vary") + if !strings.Contains(vary, "Origin") || !strings.Contains(vary, "Accept-Language") { + t.Fatalf("Vary should merge: got %q", vary) + } +} + +func TestMiddleware_WildcardIfNoneMatch(t *testing.T) { + h := httpcache.Middleware(httpcache.Options{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("anything")) + })) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("If-None-Match", "*") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotModified { + t.Fatalf("status: got %d, want 304 for *", rec.Code) + } +} + +func TestMiddleware_OverflowFallsThrough(t *testing.T) { + // Body larger than the configured max — middleware should stream + // it directly without an ETag. + big := strings.Repeat("a", 4096) + h := httpcache.Middleware(httpcache.Options{MaxBodyBytes: 64})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, big) + })) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Header().Get("ETag") != "" { + t.Fatalf("overflow path should skip ETag") + } + if rec.Body.Len() != len(big) { + t.Fatalf("body length: got %d, want %d", rec.Body.Len(), len(big)) + } +} + +func TestMiddleware_WeakETagAccepted(t *testing.T) { + h := httpcache.Middleware(httpcache.Options{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + + req1 := httptest.NewRequest(http.MethodGet, "/x", nil) + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + etag := rec1.Header().Get("ETag") + + // Echo as weak form. + req2 := httptest.NewRequest(http.MethodGet, "/x", nil) + req2.Header.Set("If-None-Match", "W/"+etag) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusNotModified { + t.Fatalf("status: got %d, want 304 (weak compare)", rec2.Code) + } +} From 3026b4e25eeda4e1587ed2b5e6ad74480ed70291 Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Tue, 26 May 2026 13:21:42 +0200 Subject: [PATCH 2/2] feat(webhooks/revalidate): ISR cache invalidation hook on publish (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #86 (partial). Adds an outbound HTTP webhook fired by apps/api/internal/rest/posts on create/update events that land or remove a row from "published" status. POSTs to {NEXT_REVALIDATE_URL}/api/revalidate?path=...&secret=... so the apps/web Next.js side can clear its ISR cache without waiting for the next revalidate interval. Configurable via: - GONEXT_NEXT_REVALIDATE_URL — apps/web origin - GONEXT_NEXT_REVALIDATE_SECRET — shared secret Either-empty disables the hook (chassis-without-renderer deployments). Notify failures are logged at Warn and swallowed — staleness for a few seconds is the right degrade behavior for a successful publish. The new revalidate.Client lives at packages/go/webhooks/revalidate rather than reusing packages/go/webhooks/delivery (which is the user-facing fan-out system with signed bodies + DLQ + retries). ISR revalidation is the opposite shape: single chassis-internal endpoint, fire-and-forget, best-effort. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Mohamed Tayeb Mokni --- .env.example | 16 ++ apps/api/cmd/server/main.go | 31 ++- apps/api/internal/rest/posts/deps.go | 21 ++ apps/api/internal/rest/posts/handlers.go | 94 ++++++- .../internal/rest/posts/revalidate_test.go | 247 ++++++++++++++++++ packages/go/config/config.go | 22 ++ packages/go/config/dump_test.go | 2 + packages/go/config/envdoc_test.go | 2 + packages/go/config/load.go | 13 + packages/go/webhooks/revalidate/client.go | 197 ++++++++++++++ .../go/webhooks/revalidate/client_test.go | 187 +++++++++++++ packages/go/webhooks/revalidate/doc.go | 47 ++++ 12 files changed, 862 insertions(+), 17 deletions(-) create mode 100644 apps/api/internal/rest/posts/revalidate_test.go create mode 100644 packages/go/webhooks/revalidate/client.go create mode 100644 packages/go/webhooks/revalidate/client_test.go create mode 100644 packages/go/webhooks/revalidate/doc.go diff --git a/.env.example b/.env.example index 07e20e34..b252477d 100644 --- a/.env.example +++ b/.env.example @@ -378,3 +378,19 @@ GONEXT_AUTH_CSRF_SECRET=change-me-32-bytes-dev-only-CCCCCCCCCC== # and dev hosts stay non-indexable by default). # Default: (GONEXT_ENV == production) # GONEXT_PUBLIC_SITE_ALLOW_INDEX=true + +# Origin of the apps/web Next.js renderer used for outbound ISR cache +# invalidation hooks. When a post or page is published, the REST surface +# POSTs to {URL}/api/revalidate?path=...&secret=... so Next.js can clear +# its incremental-static-regeneration cache. Empty disables the hook +# (chassis-without-renderer deployments). +# Default: unset +# GONEXT_NEXT_REVALIDATE_URL=https://example.com + +# Shared secret the Next.js /api/revalidate route handler validates +# before clearing its cache. Sent as the `secret` query parameter on +# the outbound POST. Empty disables the hook (same shape as an empty +# URL). Treat as a deployment secret; rotate alongside the apps/web +# matching value. +# Default: unset +# GONEXT_NEXT_REVALIDATE_SECRET=replace-with-a-strong-random-string diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index e9ee223a..e9f60af7 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -74,6 +74,7 @@ import ( "github.com/Singleton-Solution/GoNext/packages/go/session" "github.com/Singleton-Solution/GoNext/packages/go/shutdown" "github.com/Singleton-Solution/GoNext/packages/go/theme/seed" + "github.com/Singleton-Solution/GoNext/packages/go/webhooks/revalidate" ) const serviceName = "api" @@ -785,12 +786,32 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se postsStore = restposts.NewMemoryStore() } postsPolicy := policy.NewBasicPolicy(policy.DefaultRoleCapabilities()) + + // ISR revalidation client (#86). Both env vars must be set for the + // client to actually fire; an unset deployment is the + // chassis-without-Next.js case and the client's Notify becomes a + // no-op. We log enabled-ness once at boot so operators can confirm + // from the structured log stream whether ISR hooks are wired. + revalidateClient := revalidate.New( + cfg.PublicSite.NextRevalidateURL, + cfg.PublicSite.NextRevalidateSecret, + revalidate.WithLogger(logger), + ) + if revalidateClient.Enabled() { + logger.Info("rest/posts: ISR revalidate hook enabled", + slog.String("base", cfg.PublicSite.NextRevalidateURL), + ) + } else { + logger.Info("rest/posts: ISR revalidate hook disabled (GONEXT_NEXT_REVALIDATE_URL / _SECRET unset)") + } + if err := restposts.Mount(mux, "/api/v1/posts", restposts.Deps{ - Store: postsStore, - Policy: postsPolicy, - Audit: auditEmitter, - Logger: logger, - PostType: restposts.PostTypePost, + Store: postsStore, + Policy: postsPolicy, + Audit: auditEmitter, + Logger: logger, + PostType: restposts.PostTypePost, + Revalidate: revalidateClient, }); err != nil { logger.Warn("rest/posts: failed to mount", slog.Any("err", err)) } else { diff --git a/apps/api/internal/rest/posts/deps.go b/apps/api/internal/rest/posts/deps.go index cc220f75..c9f6a4c1 100644 --- a/apps/api/internal/rest/posts/deps.go +++ b/apps/api/internal/rest/posts/deps.go @@ -1,6 +1,7 @@ package posts import ( + "context" "errors" "log/slog" @@ -8,6 +9,19 @@ import ( "github.com/Singleton-Solution/GoNext/packages/go/policy" ) +// RevalidateNotifier is the surface the posts handler depends on to +// fire ISR revalidation hooks at the public renderer (apps/web). +// Production wires a *revalidate.Client; tests use nil (the handler +// gracefully no-ops) or a stub that records calls. +// +// The interface is deliberately tiny — Notify and NotifyMany — so +// tests don't have to import packages/go/webhooks/revalidate just to +// build a fake. See packages/go/webhooks/revalidate for the contract. +type RevalidateNotifier interface { + Notify(ctx context.Context, path string) error + NotifyMany(ctx context.Context, paths []string) error +} + // PostTypePost is the value for /api/v1/posts mounts. const PostTypePost = "post" @@ -46,6 +60,13 @@ type Deps struct { // sets PostTypePage. The discriminator is reflected in capability // resolution (CapEditPosts vs CapEditPages) and in every store call. PostType string + + // Revalidate, when non-nil, receives best-effort ISR cache + // invalidation hooks after a successful publish (create or + // transition-to-published). nil disables the hook entirely — the + // handler treats this as the chassis-without-apps/web deployment + // case and falls back to no-op behavior. Issue #86. + Revalidate RevalidateNotifier } // validate is called by [Mount] to fail fast on misconfigured wiring. diff --git a/apps/api/internal/rest/posts/handlers.go b/apps/api/internal/rest/posts/handlers.go index 5a86a86d..0256294a 100644 --- a/apps/api/internal/rest/posts/handlers.go +++ b/apps/api/internal/rest/posts/handlers.go @@ -50,12 +50,13 @@ func Mount(mux *http.ServeMux, base string, deps Deps) error { deps.Logger = slog.Default() } h := &handlers{ - store: deps.Store, - policy: deps.Policy, - audit: deps.Audit, - logger: deps.Logger, - postType: deps.PostType, - caps: capsFor(deps.PostType), + store: deps.Store, + policy: deps.Policy, + audit: deps.Audit, + logger: deps.Logger, + postType: deps.PostType, + caps: capsFor(deps.PostType), + revalidate: deps.Revalidate, } mux.Handle("GET "+base, h.requireAuth(h.list)) @@ -69,12 +70,13 @@ func Mount(mux *http.ServeMux, base string, deps Deps) error { // handlers carries the resolved dependencies for a single mount. One // instance per call to Mount; no global state. type handlers struct { - store Store - policy policy.Policy - audit *audit.Emitter - logger *slog.Logger - postType string - caps capabilitySet + store Store + policy policy.Policy + audit *audit.Emitter + logger *slog.Logger + postType string + caps capabilitySet + revalidate RevalidateNotifier } // requireAuth wraps a handler with the principal-presence guard. @@ -291,6 +293,13 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request, pr policy.Prin h.emitAudit(r.Context(), pr, post, "created") + // ISR revalidation (#86). Created posts only need a revalidation + // hook if they landed in "published" status — drafts and pending + // rows are not visible to the public renderer. + if post.Status == "published" { + h.notifyRevalidate(r.Context(), post, "created") + } + w.Header().Set(HeaderVersion, strconv.Itoa(post.Version)) router.SetETag(w, router.HashETag(post.hash)) router.WriteJSON(w, http.StatusCreated, post) @@ -365,6 +374,16 @@ func (h *handlers) update(w http.ResponseWriter, r *http.Request, pr policy.Prin h.emitAudit(r.Context(), pr, updated, "updated") + // ISR revalidation (#86). Two cases trigger the hook: + // 1. The row is still / now in "published" status — the public + // page needs a fresh render. + // 2. The row WAS published and is no longer (unpublished / + // trashed via a status change) — the public page needs to + // transition to 404 / removed. + if updated.Status == "published" || existing.Status == "published" { + h.notifyRevalidate(r.Context(), updated, "updated") + } + w.Header().Set(HeaderVersion, strconv.Itoa(updated.Version)) router.SetETag(w, router.HashETag(updated.hash)) router.WriteJSON(w, http.StatusOK, updated) @@ -479,6 +498,57 @@ func (h *handlers) writeStoreError(w http.ResponseWriter, r *http.Request, err e } } +// notifyRevalidate fires the ISR cache-invalidation hooks for a post +// or page that just published / unpublished. The renderer's URL +// convention is: +// +// post type "post" → /posts/{slug} +// post type "page" → /{slug} +// +// Plus the homepage feed ("/") for "post" — a new entry on the home +// list deserves a fresh render. Pages don't push to the home feed by +// default; they're typically reached via the main menu and the menu +// itself doesn't change on publish. +// +// All failures are logged and swallowed. A failed revalidation means +// the renderer serves a stale page for up to its next-revalidate +// interval, which is correct degrade behavior — failing the publish +// (rolling back the write) because Next.js was unreachable would be +// the wrong trade-off. +func (h *handlers) notifyRevalidate(ctx context.Context, post Post, verb string) { + if h.revalidate == nil { + return + } + var paths []string + switch h.postType { + case PostTypePost: + if post.Slug != "" { + paths = append(paths, "/posts/"+post.Slug) + } + // The home feed always wants a refresh when a post lands or + // drops out — the published list is at "/", not "/posts". + paths = append(paths, "/") + case PostTypePage: + if post.Slug != "" { + paths = append(paths, "/"+post.Slug) + } + } + if len(paths) == 0 { + return + } + if err := h.revalidate.NotifyMany(ctx, paths); err != nil { + // Best-effort: log at Warn (not Error) — staleness is a soft + // failure, not a runbook-paging incident. + h.logger.WarnContext(ctx, "posts: revalidate notify failed", + slog.String("post_id", post.ID), + slog.String("verb", verb), + slog.String("post_type", h.postType), + slog.Any("paths", paths), + slog.Any("err", err), + ) + } +} + // emitAudit is the audit emission shim. A nil emitter is tolerated. // Errors are logged and swallowed — audit is best-effort, never the // reason a user-facing write fails. (See packages/go/audit godoc.) diff --git a/apps/api/internal/rest/posts/revalidate_test.go b/apps/api/internal/rest/posts/revalidate_test.go new file mode 100644 index 00000000..6ab83f35 --- /dev/null +++ b/apps/api/internal/rest/posts/revalidate_test.go @@ -0,0 +1,247 @@ +package posts + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + "github.com/Singleton-Solution/GoNext/packages/go/audit" + "github.com/Singleton-Solution/GoNext/packages/go/policy" +) + +// recordingRevalidate is a RevalidateNotifier that captures the paths +// passed to Notify / NotifyMany so tests can assert what the handler +// hooked. It's the smallest possible test double — no thread-safety +// concerns because each test owns its own instance. +type recordingRevalidate struct { + mu sync.Mutex + calls [][]string // each entry is the paths argument of one Notify(Many) call +} + +func (r *recordingRevalidate) Notify(_ context.Context, path string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, []string{path}) + return nil +} + +func (r *recordingRevalidate) NotifyMany(_ context.Context, paths []string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, append([]string{}, paths...)) + return nil +} + +func (r *recordingRevalidate) Calls() [][]string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([][]string, len(r.calls)) + for i, c := range r.calls { + out[i] = append([]string{}, c...) + } + return out +} + +func newRevalidateHarness(t *testing.T, postType string) (*testHarness, *recordingRevalidate) { + t.Helper() + mux := http.NewServeMux() + store := NewMemoryStore() + auditStore := audit.NewMemoryStore() + em := audit.NewEmitter(auditStore) + pol := policy.NewBasicPolicy(policy.DefaultRoleCapabilities()) + rec := &recordingRevalidate{} + + base := "/api/v1/posts" + if postType == PostTypePage { + base = "/api/v1/pages" + } + if err := Mount(mux, base, Deps{ + Store: store, + Policy: pol, + Audit: em, + PostType: postType, + Revalidate: rec, + }); err != nil { + t.Fatalf("Mount: %v", err) + } + return &testHarness{ + mux: mux, + store: store, + audit: em, + auditStore: auditStore, + policy: pol, + postType: postType, + base: base, + }, rec +} + +func TestCreate_PublishedFiresRevalidate(t *testing.T) { + t.Parallel() + h, rec := newRevalidateHarness(t, PostTypePost) + pr := editorPrincipal("u-1") // editor has publish_posts + + title := "Hello" + status := "published" + slug := "hello-world" + req := httptest.NewRequest("POST", h.base, jsonBody(t, CreateInput{ + Title: &title, + Status: &status, + Slug: &slug, + })) + resp := h.do(req, &pr) + if resp.Code != http.StatusCreated { + t.Fatalf("status: %d, body=%s", resp.Code, resp.Body.String()) + } + + calls := rec.Calls() + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + paths := calls[0] + want := map[string]bool{"/posts/hello-world": false, "/": false} + for _, p := range paths { + if _, ok := want[p]; ok { + want[p] = true + } + } + for k, v := range want { + if !v { + t.Errorf("expected revalidate path %q, calls=%v", k, calls) + } + } +} + +func TestCreate_DraftDoesNotFireRevalidate(t *testing.T) { + t.Parallel() + h, rec := newRevalidateHarness(t, PostTypePost) + pr := authorPrincipal("u-1") + + title := "Draft" + req := httptest.NewRequest("POST", h.base, jsonBody(t, CreateInput{Title: &title})) + resp := h.do(req, &pr) + if resp.Code != http.StatusCreated { + t.Fatalf("status: %d, body=%s", resp.Code, resp.Body.String()) + } + + if calls := rec.Calls(); len(calls) != 0 { + t.Fatalf("expected zero revalidate calls for draft, got %v", calls) + } +} + +func TestUpdate_PublishTransitionFiresRevalidate(t *testing.T) { + t.Parallel() + h, rec := newRevalidateHarness(t, PostTypePost) + pr := editorPrincipal("u-1") + + // Create draft. + title := "T" + slug := "transition" + createReq := httptest.NewRequest("POST", h.base, jsonBody(t, CreateInput{Title: &title, Slug: &slug})) + createResp := h.do(createReq, &pr) + if createResp.Code != http.StatusCreated { + t.Fatalf("create: %d", createResp.Code) + } + var created Post + decodeJSON(t, createResp, &created) + + if calls := rec.Calls(); len(calls) != 0 { + t.Fatalf("draft create should not revalidate, got %v", calls) + } + + // Transition to published. + status := "published" + updateReq := httptest.NewRequest("PATCH", h.base+"/"+created.ID, jsonBody(t, UpdateInput{Status: &status})) + updateReq.Header.Set("If-Match", `"`+strconv.Itoa(created.Version)+`"`) + updateResp := h.do(updateReq, &pr) + if updateResp.Code != http.StatusOK { + t.Fatalf("update: %d body=%s", updateResp.Code, updateResp.Body.String()) + } + + calls := rec.Calls() + if len(calls) != 1 { + t.Fatalf("expected 1 call after publish, got %d", len(calls)) + } + found := false + for _, p := range calls[0] { + if p == "/posts/transition" { + found = true + } + } + if !found { + t.Errorf("expected /posts/transition in calls, got %v", calls) + } +} + +func TestPage_PublishFiresRevalidateWithoutHomepage(t *testing.T) { + t.Parallel() + h, rec := newRevalidateHarness(t, PostTypePage) + pr := editorPrincipal("u-1") + + title := "About" + status := "published" + slug := "about" + req := httptest.NewRequest("POST", h.base, jsonBody(t, CreateInput{ + Title: &title, Status: &status, Slug: &slug, + })) + resp := h.do(req, &pr) + if resp.Code != http.StatusCreated { + t.Fatalf("status: %d, body=%s", resp.Code, resp.Body.String()) + } + + calls := rec.Calls() + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + // Pages do NOT push the homepage feed — they're addressed by slug + // off the root path only. + for _, p := range calls[0] { + if p == "/" { + t.Errorf("page publish should not revalidate /, got %v", calls[0]) + } + } + found := false + for _, p := range calls[0] { + if p == "/about" { + found = true + } + } + if !found { + t.Errorf("expected /about in calls, got %v", calls) + } +} + +func TestRevalidate_NilNotifierIsTolerated(t *testing.T) { + t.Parallel() + // No revalidate dep — the handler should noop without crashing. + mux := http.NewServeMux() + store := NewMemoryStore() + auditStore := audit.NewMemoryStore() + em := audit.NewEmitter(auditStore) + pol := policy.NewBasicPolicy(policy.DefaultRoleCapabilities()) + if err := Mount(mux, "/api/v1/posts", Deps{ + Store: store, Policy: pol, Audit: em, PostType: PostTypePost, + }); err != nil { + t.Fatalf("Mount: %v", err) + } + + pr := editorPrincipal("u-1") + title := "X" + status := "published" + slug := "x" + req := httptest.NewRequest("POST", "/api/v1/posts", jsonBody(t, CreateInput{ + Title: &title, Status: &status, Slug: &slug, + })).WithContext(policy.WithPrincipal(context.Background(), pr)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) + } + // Body should still serialize; just confirm no crash. + if !strings.Contains(rec.Body.String(), `"slug":"x"`) { + t.Errorf("body: %s", rec.Body.String()) + } +} diff --git a/packages/go/config/config.go b/packages/go/config/config.go index 39adf45e..ac38f0d1 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -377,4 +377,26 @@ type PublicSiteConfig struct { // crawling. Defaults to (Env == EnvProduction). Honors // GONEXT_PUBLIC_SITE_ALLOW_INDEX. AllowIndex bool + + // NextRevalidateURL is the apps/web origin used for outbound ISR + // cache-invalidation hooks. When a post or page is published, the + // REST handler POSTs to + // {NextRevalidateURL}/api/revalidate?path=...&secret=... + // so the Next.js side can clear its incremental-static-regeneration + // cache without waiting for the next revalidate interval. + // + // Empty disables the hook entirely — useful when the API is + // deployed without apps/web (a JSON-only deployment) or when the + // renderer is served from a static host that doesn't speak ISR. + // Honors GONEXT_NEXT_REVALIDATE_URL. + NextRevalidateURL string + + // NextRevalidateSecret is the shared token the Next.js + // /api/revalidate route handler validates before clearing its + // cache. The chassis sends this as the `secret` query parameter + // on the outbound POST. + // + // Empty disables the hook (same shape as an empty + // NextRevalidateURL). Honors GONEXT_NEXT_REVALIDATE_SECRET. + NextRevalidateSecret string } diff --git a/packages/go/config/dump_test.go b/packages/go/config/dump_test.go index abc9d9c7..4ee10a1e 100644 --- a/packages/go/config/dump_test.go +++ b/packages/go/config/dump_test.go @@ -281,6 +281,8 @@ func TestDump_Golden(t *testing.T) { "Plugins.DevToken=" + expectedMask(""), "PublicSite.AllowIndex=true", "PublicSite.BaseURL=https://example.com", + "PublicSite.NextRevalidateSecret=" + expectedMask(""), + "PublicSite.NextRevalidateURL=", "RUM.Enabled=false", "RUM.SampleRate=1", "Redis.DialTimeout=5s", diff --git a/packages/go/config/envdoc_test.go b/packages/go/config/envdoc_test.go index 0c301403..f605f807 100644 --- a/packages/go/config/envdoc_test.go +++ b/packages/go/config/envdoc_test.go @@ -98,6 +98,8 @@ var envExampleAllKeys = []string{ // PublicSite "GONEXT_PUBLIC_SITE_BASE_URL", "GONEXT_PUBLIC_SITE_ALLOW_INDEX", + "GONEXT_NEXT_REVALIDATE_URL", + "GONEXT_NEXT_REVALIDATE_SECRET", } // findRepoRoot walks upward from this test file's directory until it diff --git a/packages/go/config/load.go b/packages/go/config/load.go index 0388ba88..f4c340bc 100644 --- a/packages/go/config/load.go +++ b/packages/go/config/load.go @@ -340,6 +340,19 @@ func Load(opts ...LoadOption) (*Config, error) { cfg.PublicSite.AllowIndex = b } + // ISR revalidation webhook (issue #86). Both pieces must be set + // for the hook to fire; either-empty leaves the webhook client + // disabled and the REST handlers' Notify calls become no-ops. + // We deliberately do NOT validate that the URL parses — the + // chassis must still boot if an operator typos the env var, and + // the client's first call logs the parse error rather than + // failing the boot. + cfg.PublicSite.NextRevalidateURL = strings.TrimRight( + getString(e, "GONEXT_NEXT_REVALIDATE_URL", ""), + "/", + ) + cfg.PublicSite.NextRevalidateSecret = getString(e, "GONEXT_NEXT_REVALIDATE_SECRET", "") + if len(errs) > 0 { return cfg, joinErrs(errs) } diff --git a/packages/go/webhooks/revalidate/client.go b/packages/go/webhooks/revalidate/client.go new file mode 100644 index 00000000..fe166538 --- /dev/null +++ b/packages/go/webhooks/revalidate/client.go @@ -0,0 +1,197 @@ +package revalidate + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +// DefaultTimeout bounds the HTTP request to the Next.js side. ISR +// revalidation is best-effort — a slow response means a stale cache +// page is served for a few seconds, which is preferable to blocking +// the REST POST that triggered the notify. +const DefaultTimeout = 5 * time.Second + +// HTTPClient is the small surface Notify needs from net/http. The +// concrete type used in production is *http.Client; tests substitute a +// stub. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client posts revalidation requests to the apps/web ISR endpoint. +// +// A Client with an empty BaseURL OR Secret is "disabled" — Notify +// returns nil without making a request. This is the right shape for +// the chassis's "renderer optional" deployment: operators who run the +// API standalone (or behind a static host that doesn't speak Next.js +// ISR) don't have to wire a fake URL just to silence errors. +type Client struct { + baseURL string + secret string + http HTTPClient + logger *slog.Logger +} + +// Option configures a Client. +type Option func(*Client) + +// WithHTTPClient overrides the underlying HTTP client. Tests pass a +// stub; production code accepts the default (an *http.Client with +// DefaultTimeout). +func WithHTTPClient(c HTTPClient) Option { + return func(cl *Client) { + if c != nil { + cl.http = c + } + } +} + +// WithLogger swaps the structured logger. nil keeps slog.Default(). +func WithLogger(l *slog.Logger) Option { + return func(cl *Client) { + if l != nil { + cl.logger = l + } + } +} + +// New returns a Client that will POST to baseURL/api/revalidate. Both +// baseURL and secret may be empty — the resulting Client's Notify is a +// no-op (returns nil), which is the production behavior when +// GONEXT_NEXT_REVALIDATE_URL or GONEXT_NEXT_REVALIDATE_SECRET is unset. +// +// baseURL is normalized: trailing slash trimmed (the client appends +// "/api/revalidate" verbatim, so a trailing slash would produce a +// double slash). A baseURL that doesn't parse as an absolute URL is +// kept as-is — the error surfaces on the first Notify call instead of +// at construction so a misconfigured chassis still boots (a noisy log +// line is preferred over a hard boot failure on a best-effort hook). +func New(baseURL, secret string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + secret: secret, + http: &http.Client{Timeout: DefaultTimeout}, + logger: slog.Default(), + } + for _, o := range opts { + o(c) + } + return c +} + +// Enabled reports whether the client will actually issue requests. A +// disabled client's Notify returns nil — useful for callers that want +// to skip computing the path argument when revalidation is off. +func (c *Client) Enabled() bool { + return c != nil && c.baseURL != "" && c.secret != "" +} + +// Notify POSTs a revalidation request for the given path. Path SHOULD +// be the URL path that should be revalidated on the Next.js side — +// e.g. "/" for the homepage feed, "/posts/{slug}" for a single post +// page. +// +// The function is best-effort: +// +// - Disabled client → returns nil silently. +// - Empty path → returns nil silently (nothing to revalidate). +// - HTTP transport error → returned to caller; the REST handler +// logs and continues (a failed revalidation should not roll back +// a successful publish). +// - Non-2xx response → ErrUpstream with the status code included so +// the caller can decide whether to retry. +// +// The secret is sent as a query parameter (not a header) because +// Next.js's ISR convention is `?secret=...` in the route handler. +// Sending it as a query string in a TLS-encrypted POST is fine — the +// concern with secrets-in-URLs is access log leakage, and we control +// both endpoints of this hop. +func (c *Client) Notify(ctx context.Context, path string) error { + if !c.Enabled() { + return nil + } + if path == "" { + return nil + } + + u, err := buildURL(c.baseURL, path, c.secret) + if err != nil { + return fmt.Errorf("revalidate: build url: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil) + if err != nil { + return fmt.Errorf("revalidate: new request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + // The Next.js side reads X-GoNext-Source to differentiate + // revalidation requests from other webhook traffic on the same + // origin. Cheap to set, useful in logs. + req.Header.Set("X-GoNext-Source", "rest") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("revalidate: http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return fmt.Errorf("%w: status %d", ErrUpstream, resp.StatusCode) +} + +// NotifyMany fires Notify for each non-empty path in paths. Errors are +// aggregated (errors.Join) so the caller sees all failures, not just +// the first. Empty / disabled paths are skipped silently. +// +// Used when a single publish event invalidates several routes — e.g. +// publishing a post should revalidate both "/posts/{slug}" and "/" so +// the homepage feed picks up the new entry. +func (c *Client) NotifyMany(ctx context.Context, paths []string) error { + if !c.Enabled() { + return nil + } + var errs []error + for _, p := range paths { + if p == "" { + continue + } + if err := c.Notify(ctx, p); err != nil { + errs = append(errs, err) + } + } + if len(errs) == 0 { + return nil + } + return errors.Join(errs...) +} + +// ErrUpstream is returned by Notify when the apps/web side answers +// with a non-2xx status. The error message includes the status code +// so callers can log it without a separate field; errors.Is(err, +// ErrUpstream) reports the category. +var ErrUpstream = errors.New("revalidate: upstream returned non-2xx") + +// buildURL constructs the full Next.js ISR endpoint URL. We use +// url.Parse + url.Values rather than fmt.Sprintf so callers passing a +// path with special characters (a slug with a hash, an apostrophe) +// don't produce a malformed URL. +func buildURL(base, path, secret string) (string, error) { + u, err := url.Parse(base + "/api/revalidate") + if err != nil { + return "", err + } + q := u.Query() + q.Set("path", path) + q.Set("secret", secret) + u.RawQuery = q.Encode() + return u.String(), nil +} diff --git a/packages/go/webhooks/revalidate/client_test.go b/packages/go/webhooks/revalidate/client_test.go new file mode 100644 index 00000000..64579fe7 --- /dev/null +++ b/packages/go/webhooks/revalidate/client_test.go @@ -0,0 +1,187 @@ +package revalidate_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/Singleton-Solution/GoNext/packages/go/webhooks/revalidate" +) + +type stubHTTP struct { + lastReq *http.Request + resp *http.Response + err error + callCount int + respBodies []string +} + +func (s *stubHTTP) Do(req *http.Request) (*http.Response, error) { + s.callCount++ + s.lastReq = req + if s.err != nil { + return nil, s.err + } + if s.resp != nil { + // Tests reuse the stub; rewrap a fresh Body so close-on-defer + // in the client doesn't ruin the next call. + if len(s.respBodies) > 0 { + s.resp.Body = io.NopCloser(strings.NewReader(s.respBodies[0])) + s.respBodies = s.respBodies[1:] + } else { + s.resp.Body = io.NopCloser(&bytes.Buffer{}) + } + return s.resp, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(&bytes.Buffer{})}, nil +} + +func TestClient_NoopWhenDisabled(t *testing.T) { + c := revalidate.New("", "secret") + if err := c.Notify(context.Background(), "/posts/x"); err != nil { + t.Fatalf("disabled client should noop, got %v", err) + } + if c.Enabled() { + t.Fatalf("expected Enabled()=false") + } + + c2 := revalidate.New("https://example.com", "") + if err := c2.Notify(context.Background(), "/posts/x"); err != nil { + t.Fatalf("no-secret should noop, got %v", err) + } +} + +func TestClient_NoopOnEmptyPath(t *testing.T) { + stub := &stubHTTP{} + c := revalidate.New("https://example.com", "topsecret", revalidate.WithHTTPClient(stub)) + if err := c.Notify(context.Background(), ""); err != nil { + t.Fatalf("empty path should noop, got %v", err) + } + if stub.callCount != 0 { + t.Fatalf("expected no HTTP calls, got %d", stub.callCount) + } +} + +func TestClient_NotifyBuildsURL(t *testing.T) { + stub := &stubHTTP{ + resp: &http.Response{StatusCode: http.StatusOK}, + } + c := revalidate.New("https://example.com/", "topsecret", revalidate.WithHTTPClient(stub)) + + if err := c.Notify(context.Background(), "/posts/hello-world"); err != nil { + t.Fatalf("Notify: %v", err) + } + if stub.callCount != 1 { + t.Fatalf("expected 1 call, got %d", stub.callCount) + } + + u := stub.lastReq.URL + if u.Path != "/api/revalidate" { + t.Fatalf("path: got %q, want /api/revalidate", u.Path) + } + q := u.Query() + if q.Get("path") != "/posts/hello-world" { + t.Fatalf("path query: got %q", q.Get("path")) + } + if q.Get("secret") != "topsecret" { + t.Fatalf("secret query: got %q", q.Get("secret")) + } + if stub.lastReq.Method != http.MethodPost { + t.Fatalf("method: got %q, want POST", stub.lastReq.Method) + } +} + +func TestClient_BaseURLTrailingSlashTrimmed(t *testing.T) { + stub := &stubHTTP{ + resp: &http.Response{StatusCode: http.StatusOK}, + } + c := revalidate.New("https://example.com/", "topsecret", revalidate.WithHTTPClient(stub)) + _ = c.Notify(context.Background(), "/") + if !strings.HasPrefix(stub.lastReq.URL.String(), "https://example.com/api/revalidate?") { + t.Fatalf("URL: got %q", stub.lastReq.URL.String()) + } +} + +func TestClient_HTTPError(t *testing.T) { + stub := &stubHTTP{err: errors.New("dial: no route to host")} + c := revalidate.New("https://example.com", "topsecret", revalidate.WithHTTPClient(stub)) + + err := c.Notify(context.Background(), "/x") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "no route to host") { + t.Fatalf("error: %v", err) + } +} + +func TestClient_Non2xxIsUpstream(t *testing.T) { + stub := &stubHTTP{ + resp: &http.Response{StatusCode: http.StatusUnauthorized}, + } + c := revalidate.New("https://example.com", "topsecret", revalidate.WithHTTPClient(stub)) + + err := c.Notify(context.Background(), "/x") + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, revalidate.ErrUpstream) { + t.Fatalf("expected ErrUpstream, got %v", err) + } +} + +func TestClient_NotifyMany(t *testing.T) { + stub := &stubHTTP{ + resp: &http.Response{StatusCode: http.StatusOK}, + } + c := revalidate.New("https://example.com", "topsecret", revalidate.WithHTTPClient(stub)) + + if err := c.NotifyMany(context.Background(), []string{"/", "/posts/x", ""}); err != nil { + t.Fatalf("NotifyMany: %v", err) + } + if stub.callCount != 2 { + t.Fatalf("expected 2 calls (empty skipped), got %d", stub.callCount) + } +} + +func TestClient_NotifyManyAggregatesErrors(t *testing.T) { + stub := &stubHTTP{err: errors.New("transport down")} + c := revalidate.New("https://example.com", "topsecret", revalidate.WithHTTPClient(stub)) + + err := c.NotifyMany(context.Background(), []string{"/a", "/b"}) + if err == nil { + t.Fatalf("expected error") + } + // errors.Join wraps both; check string contains both URLs' worth. + if !strings.Contains(err.Error(), "transport down") { + t.Fatalf("err: %v", err) + } +} + +func TestClient_NotifyManyDisabledNoop(t *testing.T) { + c := revalidate.New("", "") + if err := c.NotifyMany(context.Background(), []string{"/a", "/b"}); err != nil { + t.Fatalf("disabled client should noop, got %v", err) + } +} + +func TestClient_PathWithSpecialChars(t *testing.T) { + stub := &stubHTTP{ + resp: &http.Response{StatusCode: http.StatusOK}, + } + c := revalidate.New("https://example.com", "topsecret", revalidate.WithHTTPClient(stub)) + + if err := c.Notify(context.Background(), "/posts/hello world & friends"); err != nil { + t.Fatalf("Notify: %v", err) + } + // Should be percent-encoded. + parsed, _ := url.Parse(stub.lastReq.URL.String()) + if got := parsed.Query().Get("path"); got != "/posts/hello world & friends" { + t.Fatalf("decoded path: %q", got) + } +} diff --git a/packages/go/webhooks/revalidate/doc.go b/packages/go/webhooks/revalidate/doc.go new file mode 100644 index 00000000..d28de902 --- /dev/null +++ b/packages/go/webhooks/revalidate/doc.go @@ -0,0 +1,47 @@ +// Package revalidate implements the outbound HTTP webhook fired by the +// REST surface when a post or page is published or updated. It's the +// "tell apps/web that an ISR cache entry just went stale" hook. +// +// # Contract +// +// On a publish or update event, the REST handler calls Client.Notify +// with the path that should be revalidated (typically "/" for the home +// feed, "/posts/{slug}" for a single post). The client issues a POST +// to: +// +// {NEXT_REVALIDATE_URL}/api/revalidate?path={path}&secret={secret} +// +// where {NEXT_REVALIDATE_URL} is the apps/web origin (e.g. +// "https://example.com") and {secret} is a shared HMAC-equivalent token +// the Next.js route handler validates before clearing the cache. +// +// Configuration: +// +// - GONEXT_NEXT_REVALIDATE_URL — apps/web origin +// - GONEXT_NEXT_REVALIDATE_SECRET — shared secret +// +// When EITHER is empty the client is a no-op — the chassis runs without +// the renderer (or against a non-ISR static host) and the REST handler +// shouldn't break in that mode. +// +// # Why not the existing webhooks/delivery framework +// +// packages/go/webhooks/delivery is the user-facing webhook fan-out +// system (operators register N webhooks per event, signed bodies, +// retries, dead-letter queue). That's the right shape for "tell the +// operator's Zapier integration about new posts". +// +// The ISR revalidation hook is the OPPOSITE shape: it's a single, +// chassis-internal endpoint that lives in apps/web by convention; the +// failure mode is "stale cache for a few seconds until the next ISR +// revalidation kicks in", not "lost integration event". A retry queue +// + signed body + DLQ would be overkill — a fire-and-forget POST with +// a short timeout is exactly the right surface. +// +// # Failure mode +// +// Notify returns errors so callers can decide whether to surface them. +// In practice the REST handlers log-and-swallow: a failed revalidation +// is a cache-staleness issue, not a reason to fail a successful POST +// of an article. +package revalidate