diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index 5b129a95..8a4eadd3 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -450,6 +450,10 @@ Guideline: - Ensure pages provide the configured JaWS resources and Request key metadata; `HeadHTML` is the usual way to emit them. +- Include the `no-store` Cache-Control directive on every page response containing + `HeadHTML` or equivalent Request-key metadata; the key is a one-use capability. + `ui.Handler` sets this automatically, while custom page handlers must set it + explicitly. - `TailHTML` is optional; it applies queued attr/class updates before the WebSocket connects and can reduce initial flicker. - Register the JaWS `/jaws/` route prefix correctly and pair request creation with `UseRequest` handling. diff --git a/AI.md b/AI.md index dfb1502c..bbe0b275 100644 --- a/AI.md +++ b/AI.md @@ -115,6 +115,13 @@ The normal page flow has two related HTTP requests: key, claims the pending Request through `UseRequest`, upgrades the connection, and begins event and DOM-update processing. +Page responses containing `HeadHTML` or equivalent Request-key metadata must +include the `no-store` Cache-Control directive. An HTTP-cached copy would repeat +the consumed one-use capability and cannot establish another WebSocket +connection. `ui.Handler` sets `Cache-Control: no-store` automatically; custom +page handlers must set it explicitly. A bfcache restoration is handled +separately by the bundled client's `pageshow` reload. + Applications that emit equivalent resources and metadata need not call `HeadHTML` or `TailHTML`. Custom routers may parse the trailing key with `key.Parse`, call `UseRequest`, return 404 when it is absent, and then call the diff --git a/lib/ui/AI.md b/lib/ui/AI.md index d8d4a9b9..9823a096 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -76,7 +76,10 @@ the dot callback returns an attribute with the same name. When dot implements each wrapper's initial render; equal Template values may therefore render different Element-specific attributes without storing them in the Template. The callback is not invoked during `Template.JawsUpdate`. Full page templates -belong in `ui.Handler`; static structural inclusion should use Go's native +belong in `ui.Handler`, which marks every response `Cache-Control: no-store` so +an HTTP cache does not reuse a one-use Request key emitted by +`Request.HeadHTML`. Custom page handlers that emit `Request.HeadHTML` must +include the same directive. Static structural inclusion should use Go's native template action: ```gotemplate diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 9fe64c89..581b4473 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -89,6 +89,7 @@ func (sr *statusRecorder) WriteHeader(code int) { } func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") rq := h.NewRequest(r) sr := &statusRecorder{ResponseWriter: w} rw := RequestWriter{Request: rq, Writer: sr} @@ -120,7 +121,10 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // lookupers and rendered with a [With] value as the template data, exposing // dot through its Dot field. Unless the response already has a Content-Type, // Handler sets it to "text/html; charset=utf-8" when rendering writes its first -// bytes. A render failure before any output retains [http.Error]'s text response. +// bytes. Handler always sets Cache-Control to "no-store", replacing any value +// already present, so an HTTP cache does not reuse responses containing the +// one-use request key emitted by [jaws.Request.HeadHTML]. A render failure before +// any output retains [http.Error]'s text response. // // Handler renders without a generated wrapper and does not use dot as a tag, so // dot may be arbitrary template data. The handler reuses dot across requests; diff --git a/lib/ui/handler_content_type_test.go b/lib/ui/handler_headers_test.go similarity index 87% rename from lib/ui/handler_content_type_test.go rename to lib/ui/handler_headers_test.go index e27ff143..52b105b3 100644 --- a/lib/ui/handler_content_type_test.go +++ b/lib/ui/handler_headers_test.go @@ -62,6 +62,25 @@ func TestHandler_PreservesExplicitContentType(t *testing.T) { } } +func TestHandler_SetsCacheControlNoStore(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + if err = jw.AddTemplateLookuper(template.Must(template.New("page").Parse("
hello
"))); err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + rr.Header().Set("Cache-Control", "public, max-age=3600") + Handler(jw, "page", nil).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if got, want := rr.Header().Get("Cache-Control"), "no-store"; got != want { + t.Fatalf("Cache-Control = %q, want %q", got, want) + } +} + func TestHandler_RenderFailureBeforeOutputUsesTextContentType(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -79,6 +98,9 @@ func TestHandler_RenderFailureBeforeOutputUsesTextContentType(t *testing.T) { if got, want := rr.Header().Get("Content-Type"), "text/plain; charset=utf-8"; got != want { t.Fatalf("Content-Type = %q, want %q", got, want) } + if got, want := rr.Header().Get("Cache-Control"), "no-store"; got != want { + t.Fatalf("Cache-Control = %q, want %q", got, want) + } } func TestHandler_EmptyWriteBeforeErrorReturns500(t *testing.T) { diff --git a/request.go b/request.go index fe8ea01d..c84c51c5 100644 --- a/request.go +++ b/request.go @@ -453,6 +453,11 @@ func (rq *Request) releaseBuffersLocked() (buffers *requestBuffers) { } // HeadHTML writes the configured resources and Request key metadata for the page head. +// +// An HTTP response containing this output must include the "no-store" +// Cache-Control directive. [github.com/linkdata/jaws/lib/ui.Handler] sets it +// automatically. The metadata includes a one-use Request key; a copy replayed +// from an HTTP cache cannot establish another WebSocket connection. func (rq *Request) HeadHTML(w io.Writer) (err error) { rq.mu.RLock() jawsKey := rq.JawsKey