diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index 8a4eadd3..0db11d45 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -448,12 +448,10 @@ Guideline: ## Request/session integration rules -- 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. +- Call `jw.NewRequest(w, r)` before writing each page response. It replaces + Cache-Control with `no-store`. +- Include the configured JaWS resources and Request key metadata; `HeadHTML` + emits them but does not manage response headers. - `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 bbe0b275..66ad8e25 100644 --- a/AI.md +++ b/AI.md @@ -107,20 +107,17 @@ identity and multiplicity. The normal page flow has two related HTTP requests: -1. A page handler creates a Request with `Jaws.NewRequest`. `HeadHTML` normally - emits the configured resources and request-key metadata. `TailHTML` is - optional; placing it before `` applies queued initial updates before - the WebSocket connects and can reduce flicker. +1. Before writing the response, a page handler calls `Jaws.NewRequest(w, r)`, + which replaces `Cache-Control` with `no-store`. `HeadHTML` normally emits the + configured resources and request-key metadata. `TailHTML` is optional; + placing it before `` applies queued initial updates before the + WebSocket connects and can reduce flicker. 2. The bundled script connects to `/jaws/`. `Jaws.ServeHTTP` decodes the 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. +`HeadHTML` does not manage response headers. The bundled client reloads pages +restored from the bfcache. Applications that emit equivalent resources and metadata need not call `HeadHTML` or `TailHTML`. Custom routers may parse the trailing key with diff --git a/element_create_benchmark_test.go b/element_create_benchmark_test.go index b1aae230..a21d350c 100644 --- a/element_create_benchmark_test.go +++ b/element_create_benchmark_test.go @@ -44,7 +44,7 @@ func BenchmarkElementCreateBatch(b *testing.B) { b.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { b.Fatal("nil request") } diff --git a/element_test.go b/element_test.go index b1504539..b6694a4d 100644 --- a/element_test.go +++ b/element_test.go @@ -110,7 +110,7 @@ func TestElement_JsCallQueuesElementScopedCall(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { t.Fatal("NewRequest returned nil") } @@ -234,7 +234,7 @@ func TestElement_Queued(t *testing.T) { }, } - pendingRq := rq.Jaws.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + pendingRq := rq.Jaws.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) th.NoErr(testRequestWriter{rq: pendingRq, Writer: httptest.NewRecorder()}.UI(tss)) th.NoErr(rq.UI(tss)) @@ -264,7 +264,7 @@ func TestElement_ChildOperations(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) parent := rq.NewElement(&testUi{}) @@ -338,9 +338,9 @@ func TestElement_ChildOperationsRejectInvalidElement(t *testing.T) { defer jw.Close() logger := &captureErrorLogger{} jw.Logger = logger - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) - other := jw.NewRequest(nil) + other := jw.newRequest(nil) defer jw.recycle(other) parent := rq.NewElement(&testUi{}) child := tt.child(parent, other) @@ -376,7 +376,7 @@ func TestElement_ChildOperationsOnDeletedParentAreInert(t *testing.T) { defer jw.Close() logger := &captureErrorLogger{} jw.Logger = logger - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) parent := rq.NewElement(&testUi{}) child := rq.NewElement(&testUi{}) @@ -408,7 +408,7 @@ func TestElement_ReplaceRejectsMissingId(t *testing.T) { defer jw.Close() logger := &captureErrorLogger{} jw.Logger = logger - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) e := rq.NewElement(&testUi{s: "foo"}) if deadlock.Debug { @@ -459,9 +459,9 @@ func TestElement_AttrHelpersRejectReservedId(t *testing.T) { defer jw.Close() logger := &captureErrorLogger{} jw.Logger = logger - // A plain NewRequest has no running process loop, so nothing drains + // A plain Request has no running process loop, so nothing drains // wsQueue underneath the assertion (unlike newTestRequest). - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) e := rq.NewElement(&testUi{}) @@ -503,9 +503,9 @@ func TestElement_AttrHelpersAllowNormalAttr(t *testing.T) { t.Fatal(err) } defer jw.Close() - // A plain NewRequest has no running process loop, so nothing drains wsQueue + // A plain Request has no running process loop, so nothing drains wsQueue // underneath the assertion (unlike newTestRequest). - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) e := rq.NewElement(&testUi{}) e.SetAttr("hidden", "yes") @@ -666,7 +666,7 @@ func TestElement_RenderDebugAndDeletedBranches(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) tu := &testUi{renderFn: func(*Element, io.Writer, []any) error { return nil }} elem := rq.NewElement(tu) @@ -720,7 +720,7 @@ func TestElement_JawsRenderDebugTagCanReenterRequest(t *testing.T) { t.Fatal(err) } jw.Debug = true - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) tu := &testUi{renderFn: func(elem *Element, _ io.Writer, _ []any) error { elem.Tag(testReentrantDebugTag{rq: rq}) @@ -759,7 +759,7 @@ func TestElement_JawsRenderReturnsDebugWriteError(t *testing.T) { } defer jw.Close() jw.Debug = true - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) elem := rq.NewElement(&testUi{}) wantErr := errors.New("debug write failed") @@ -775,7 +775,7 @@ func TestElement_RenderDebugSanitizesHTML5CommentClose(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) rq.Jaws.Debug = true tu := &testUi{renderFn: func(*Element, io.Writer, []any) error { return nil }} @@ -976,7 +976,7 @@ func TestElement_ApplyGetter_NonComparableHandler_NilLogger(t *testing.T) { t.Fatal("expected nil Logger by default") } - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) e := rq.NewElement(&testUi{s: "x"}) tch := testNonComparableClickHandler{names: []string{"name"}} gotTag := e.ApplyGetter(tch) @@ -1003,7 +1003,7 @@ func TestElement_ApplyGetter_NonComparableHandler_NoLog(t *testing.T) { var buf bytes.Buffer jw.Logger = slog.New(slog.NewTextHandler(&buf, nil)) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) e := rq.NewElement(&testUi{s: "x"}) tch := testNonComparableClickHandler{names: []string{"name"}} e.ApplyGetter(tch) diff --git a/examples/minesweeper/main_benchmark_test.go b/examples/minesweeper/main_benchmark_test.go index ad1a7ece..944cc636 100644 --- a/examples/minesweeper/main_benchmark_test.go +++ b/examples/minesweeper/main_benchmark_test.go @@ -31,7 +31,7 @@ func BenchmarkSingleCellDirtyFanout(b *testing.B) { } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { b.Fatal("expected a request") } diff --git a/jaws_test.go b/jaws_test.go index 5e46e597..a0458555 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -200,7 +200,7 @@ func TestJaws_CloseClearsSessions(t *testing.T) { } value := "value-" + strconv.Itoa(i) sess.Set("key", value) - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if got := rq.Session(); got != sess { t.Fatalf("Request.Session() = %p, want %p", got, sess) } @@ -343,7 +343,7 @@ func TestJaws_CloseCancelsPendingHTTPWork(t *testing.T) { requestStarted := make(chan startedRequest, 1) handlerDone := make(chan *Element, 1) handler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) requestStarted <- startedRequest{rq: rq, sentinel: rq.NewElement(&testUi{})} <-rq.Context().Done() // A normal HTTP owner may still run cleanup after observing cancellation. @@ -386,7 +386,7 @@ func TestJaws_CloseRejectsClaimedRequestBeforeServe(t *testing.T) { } initial := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) initial.RemoteAddr = "192.0.2.1:1000" - rq := jw.NewRequest(initial) + rq := jw.newRequest(initial) sentinel := rq.NewElement(&testUi{}) key := rq.JawsKey websocketRequest := httptest.NewRequest(http.MethodGet, "http://example.test/jaws/"+key.String(), nil) @@ -419,9 +419,9 @@ func TestJaws_CloseHandlesRetiredKeyReservation(t *testing.T) { } jw.MaxPendingRequestsPerIP = 1 - retired := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1000")) + retired := jw.newRequest(newPendingLimitRequest("192.0.2.1:1000")) setPendingLimitLastWrite(t, retired, 3600) - replacement := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1001")) + replacement := jw.newRequest(newPendingLimitRequest("192.0.2.1:1001")) jw.Close() if got := jw.RequestCount(); got != 0 { @@ -458,7 +458,7 @@ func TestJaws_CloseCancelsMixedRequestsWithoutLogging(t *testing.T) { close(serveDone) }() - pending := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/pending", nil)) + pending := jw.newRequest(httptest.NewRequest(http.MethodGet, "/pending", nil)) active := NewTestRequest(jw, httptest.NewRequest(http.MethodGet, "/active", nil)) if active == nil { t.Fatal("NewTestRequest returned nil") @@ -525,7 +525,7 @@ func TestJaws_NewRequestRacingCloseStartsCanceled(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/", nil) go func() { <-start - requestCh <- jw.NewRequest(request) + requestCh <- jw.newRequest(request) }() go func() { <-start @@ -545,7 +545,7 @@ func TestJaws_NewRequestRacingCloseStartsCanceled(t *testing.T) { } requestsBefore := jw.RequestCount() pendingBefore := jw.Pending() - postClose := jw.NewRequest(request) + postClose := jw.newRequest(request) select { case <-postClose.Context().Done(): default: @@ -569,9 +569,9 @@ func TestJaws_MaxPendingRequestsPerIPDisabled(t *testing.T) { defer jw.Close() jw.MaxPendingRequestsPerIP = limit - jw.NewRequest(newPendingLimitRequest("192.0.2.1:1000")) - jw.NewRequest(newPendingLimitRequest("192.0.2.1:1001")) - jw.NewRequest(newPendingLimitRequest("192.0.2.1:1002")) + jw.newRequest(newPendingLimitRequest("192.0.2.1:1000")) + jw.newRequest(newPendingLimitRequest("192.0.2.1:1001")) + jw.newRequest(newPendingLimitRequest("192.0.2.1:1002")) if got := jw.Pending(); got != 3 { t.Fatalf("Pending() with limit %d = %d, want 3", limit, got) @@ -591,17 +591,17 @@ func TestJaws_MaxPendingRequestsPerIPEvictsOldestPending(t *testing.T) { jw.MaxPendingRequestsPerIP = 2 oldReq := newPendingLimitRequest("192.0.2.1:1000") - oldRq := jw.NewRequest(oldReq) + oldRq := jw.newRequest(oldReq) oldKey := oldRq.JawsKey setPendingLimitLastWrite(t, oldRq, 7200) midReq := newPendingLimitRequest("192.0.2.1:1001") - midRq := jw.NewRequest(midReq) + midRq := jw.newRequest(midReq) midKey := midRq.JawsKey setPendingLimitLastWrite(t, midRq, 3600) newReq := newPendingLimitRequest("192.0.2.1:1002") - newRq := jw.NewRequest(newReq) + newRq := jw.newRequest(newReq) newKey := newRq.JawsKey if got := jw.Pending(); got != 2 { @@ -630,7 +630,7 @@ func TestJaws_MaxPendingRequestsPerIPUsesLiveElapsedBeforeServe(t *testing.T) { jw.MaxPendingRequestsPerIP = 1 oldReq := newPendingLimitRequest("192.0.2.1:1000") - oldRq := jw.NewRequest(oldReq) + oldRq := jw.newRequest(oldReq) oldKey := oldRq.JawsKey // Simulate an hour passing before Serve starts. runtimeSeconds still holds @@ -638,7 +638,7 @@ func TestJaws_MaxPendingRequestsPerIPUsesLiveElapsedBeforeServe(t *testing.T) { // whether the pending cap may evict oldRq. jw.created = time.Now().Add(-time.Hour) newReq := newPendingLimitRequest("192.0.2.1:1001") - newRq := jw.NewRequest(newReq) + newRq := jw.newRequest(newReq) if got := jw.Pending(); got != 1 { t.Fatalf("Pending() = %d, want 1", got) @@ -686,7 +686,7 @@ func TestJaws_MaintenanceLoggerCanReenter(t *testing.T) { jw.Logger = reentrantLogger{jw: jw, logged: logged} // A pending request idle long enough for the maintenance pass to retire it. - rq := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1000")) + rq := jw.newRequest(newPendingLimitRequest("192.0.2.1:1000")) setPendingLimitLastWrite(t, rq, 3600) done := make(chan struct{}) @@ -751,14 +751,14 @@ func TestJaws_RetiredPendingRequestRemainsOwnedByInitialHTTPHandler(t *testing.T mux := http.NewServeMux() mux.HandleFunc("/blocked", func(w http.ResponseWriter, r *http.Request) { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) started <- initialRender{rq: rq, elem: rq.NewElement(&testUi{})} <-release resumed <- rq.NewElement(&testUi{}) w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/next", func(w http.ResponseWriter, r *http.Request) { - next <- jw.NewRequest(r) + next <- jw.newRequest(r) w.WriteHeader(http.StatusNoContent) }) server := httptest.NewServer(mux) @@ -856,13 +856,13 @@ func TestJaws_RetiredRequestKeyRemainsReserved(t *testing.T) { setRandomKeys(t, jw, retiredKey, retiredKey, replacementKey) retiredHTTP := newPendingLimitRequest("192.0.2.1:1000") - retired := jw.NewRequest(retiredHTTP) + retired := jw.newRequest(retiredHTTP) if retired.JawsKey != retiredKey { t.Fatalf("first key = %v, want %v", retired.JawsKey, retiredKey) } setPendingLimitLastWrite(t, retired, 3600) - replacement := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1001")) + replacement := jw.newRequest(newPendingLimitRequest("192.0.2.1:1001")) if replacement.JawsKey != replacementKey { t.Fatalf("replacement key = %v, want %v after rejecting retired key", replacement.JawsKey, replacementKey) } @@ -907,7 +907,7 @@ func TestReleaseRetiredRequestKey(t *testing.T) { } setRandomKeys(t, jw, retiredKey) - rq := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1000")) + rq := jw.newRequest(newPendingLimitRequest("192.0.2.1:1000")) if rq.JawsKey != retiredKey { t.Fatalf("reused key = %v, want %v", rq.JawsKey, retiredKey) } @@ -921,7 +921,7 @@ func TestReleaseRetiredRequestKey(t *testing.T) { } defer jw.Close() setRandomKeys(t, jw, retiredKey) - rq := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1000")) + rq := jw.newRequest(newPendingLimitRequest("192.0.2.1:1000")) releaseRetiredRequestKey(retiredRequestKey{jw: weak.Make(jw), jawsKey: retiredKey}) if got := jw.requests[retiredKey]; got != rq { @@ -945,7 +945,7 @@ func TestJaws_MaxPendingRequestsPerIPSparesRenderingRequest(t *testing.T) { jw.MaxPendingRequestsPerIP = 2 renderingReq := newPendingLimitRequest("192.0.2.1:1000") - renderingRq := jw.NewRequest(renderingReq) + renderingRq := jw.newRequest(renderingReq) renderingKey := renderingRq.JawsKey // Simulate an in-flight initial render on the oldest pending Request: a fresh // write timestamp. maintenanceInterval is zero here, so the spare window uses @@ -953,14 +953,14 @@ func TestJaws_MaxPendingRequestsPerIPSparesRenderingRequest(t *testing.T) { renderingRq.MarkWritten() idleReq := newPendingLimitRequest("192.0.2.1:1001") - idleRq := jw.NewRequest(idleReq) + idleRq := jw.newRequest(idleReq) idleKey := idleRq.JawsKey // Age the idle Request well past the spare window so it is the eviction victim. setPendingLimitLastWrite(t, idleRq, 3600) // Creating a third same-IP Request trips the cap (pending == 2). newReq := newPendingLimitRequest("192.0.2.1:1002") - newRq := jw.NewRequest(newReq) + newRq := jw.newRequest(newReq) newKey := newRq.JawsKey // The rendering Request must survive; the idle one is the one evicted. @@ -995,19 +995,19 @@ func TestJaws_MaxPendingRequestsPerIPSparesRecentlyRenderedRequest(t *testing.T) // The oldest pending Request wrote recently (its render is still in flight). renderingReq := newPendingLimitRequest("192.0.2.1:1000") - renderingRq := jw.NewRequest(renderingReq) + renderingRq := jw.newRequest(renderingReq) renderingKey := renderingRq.JawsKey renderingRq.MarkWritten() // A genuinely idle Request that rendered long ago is the correct eviction victim. idleReq := newPendingLimitRequest("192.0.2.1:1001") - idleRq := jw.NewRequest(idleReq) + idleRq := jw.newRequest(idleReq) idleKey := idleRq.JawsKey setPendingLimitLastWrite(t, idleRq, 3600) // A third same-IP Request trips the cap (pending == 2). newReq := newPendingLimitRequest("192.0.2.1:1002") - newRq := jw.NewRequest(newReq) + newRq := jw.newRequest(newReq) newKey := newRq.JawsKey // The recently-rendered Request must survive despite its cleared flag; the idle one @@ -1040,7 +1040,7 @@ func TestJaws_MaxPendingRequestsPerIPSparesStalledLiveRender(t *testing.T) { // Oldest pending: a single write, then silence. Its timestamp stays fresh. stalledReq := newPendingLimitRequest("192.0.2.1:1000") - stalledRq := jw.NewRequest(stalledReq) + stalledRq := jw.newRequest(stalledReq) stalledKey := stalledRq.JawsKey stalledRq.MarkWritten() @@ -1051,13 +1051,13 @@ func TestJaws_MaxPendingRequestsPerIPSparesStalledLiveRender(t *testing.T) { // A genuinely idle sibling is the correct victim. idleReq := newPendingLimitRequest("192.0.2.1:1001") - idleRq := jw.NewRequest(idleReq) + idleRq := jw.newRequest(idleReq) idleKey := idleRq.JawsKey setPendingLimitLastWrite(t, idleRq, 3600) // A third same-IP Request trips the cap (pending == 2). newReq := newPendingLimitRequest("192.0.2.1:1002") - newRq := jw.NewRequest(newReq) + newRq := jw.newRequest(newReq) newKey := newRq.JawsKey if claimed := jw.UseRequest(stalledKey, stalledReq); claimed != stalledRq { @@ -1080,13 +1080,13 @@ func TestJaws_MaxPendingRequestsPerIPEnforcesCapWithFutureWriteTimestamp(t *test jw.MaxPendingRequestsPerIP = 1 renderingReq := newPendingLimitRequest("192.0.2.1:1000") - renderingRq := jw.NewRequest(renderingReq) + renderingRq := jw.newRequest(renderingReq) renderingKey := renderingRq.JawsKey nowSeconds := jw.runtimeSeconds.Load() renderingRq.lastWriteSeconds.Store(nowSeconds + 1) newReq := newPendingLimitRequest("192.0.2.1:1001") - newRq := jw.NewRequest(newReq) + newRq := jw.newRequest(newReq) newKey := newRq.JawsKey if got := jw.Pending(); got != 1 { @@ -1114,12 +1114,12 @@ func TestJaws_MaxPendingRequestsPerIPEnforcesCapWhenAllRendering(t *testing.T) { jw.MaxPendingRequestsPerIP = 1 req1 := newPendingLimitRequest("192.0.2.1:1000") - rq1 := jw.NewRequest(req1) + rq1 := jw.newRequest(req1) rq1Key := rq1.JawsKey rq1.MarkWritten() req2 := newPendingLimitRequest("192.0.2.1:1001") - rq2 := jw.NewRequest(req2) + rq2 := jw.newRequest(req2) if got := jw.Pending(); got != 1 { t.Fatalf("Pending() = %d, want 1", got) } @@ -1146,10 +1146,10 @@ func TestJaws_MaxPendingRequestsPerIPEvictsLeastRecentlyWrittenWhenAllFresh(t *t jw.MaxPendingRequestsPerIP = 2 req1 := newPendingLimitRequest("192.0.2.1:1000") - rq1 := jw.NewRequest(req1) + rq1 := jw.newRequest(req1) rq1Key := rq1.JawsKey req2 := newPendingLimitRequest("192.0.2.1:1001") - rq2 := jw.NewRequest(req2) + rq2 := jw.newRequest(req2) rq2Key := rq2.JawsKey // The older rq1 wrote more recently than rq2 (a future timestamp keeps it // fresh regardless of test scheduling), so rq2 is the eviction victim even @@ -1157,7 +1157,7 @@ func TestJaws_MaxPendingRequestsPerIPEvictsLeastRecentlyWrittenWhenAllFresh(t *t setPendingLimitLastWrite(t, rq1, -3600) req3 := newPendingLimitRequest("192.0.2.1:1002") - rq3 := jw.NewRequest(req3) + rq3 := jw.newRequest(req3) if got := jw.Pending(); got != 2 { t.Fatalf("Pending() = %d, want 2", got) } @@ -1188,11 +1188,11 @@ func TestJaws_MaxPendingRequestsPerIPToleratesUnretirableVictim(t *testing.T) { jw.MaxPendingRequestsPerIP = 1 req1 := newPendingLimitRequest("192.0.2.1:1000") - rq1 := jw.NewRequest(req1) + rq1 := jw.newRequest(req1) rq1.storeState(reqRunning) defer rq1.storeState(reqPending) - rq2 := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1001")) + rq2 := jw.newRequest(newPendingLimitRequest("192.0.2.1:1001")) if got := jw.Pending(); got != 2 { t.Fatalf("Pending() = %d, want 2 (overshoot when the victim cannot be retired)", got) } @@ -1224,7 +1224,7 @@ func TestJaws_MaxPendingRequestsPerIPConcurrentCreation(t *testing.T) { go func() { defer wg.Done() <-start - requests[i] = jw.NewRequest(newPendingLimitRequest("192.0.2.1:1000")) + requests[i] = jw.newRequest(newPendingLimitRequest("192.0.2.1:1000")) }() } close(start) @@ -1253,9 +1253,9 @@ func TestJaws_MaxPendingRequestsPerIPKeepsDifferentIPs(t *testing.T) { jw.MaxPendingRequestsPerIP = 1 reqA := newPendingLimitRequest("192.0.2.1:1000") - rqA := jw.NewRequest(reqA) + rqA := jw.newRequest(reqA) reqB := newPendingLimitRequest("198.51.100.1:1000") - rqB := jw.NewRequest(reqB) + rqB := jw.newRequest(reqB) if got := jw.Pending(); got != 2 { t.Fatalf("Pending() = %d, want 2", got) @@ -1277,7 +1277,7 @@ func TestJaws_MaxPendingRequestsPerIPIgnoresClaimedRequests(t *testing.T) { jw.MaxPendingRequestsPerIP = 1 claimedReq := newPendingLimitRequest("192.0.2.1:1000") - claimedRq := jw.NewRequest(claimedReq) + claimedRq := jw.newRequest(claimedReq) if claimed := jw.UseRequest(claimedRq.JawsKey, claimedReq); claimed != claimedRq { t.Fatalf("claimed request claim = %v, want %v", claimed, claimedRq) } @@ -1286,7 +1286,7 @@ func TestJaws_MaxPendingRequestsPerIPIgnoresClaimedRequests(t *testing.T) { } pendingReq := newPendingLimitRequest("192.0.2.1:1001") - pendingRq := jw.NewRequest(pendingReq) + pendingRq := jw.newRequest(pendingReq) total, active := jw.RequestCounts() if total != 2 || active != 0 { @@ -1315,11 +1315,11 @@ func TestJaws_MaxPendingRequestsPerIPKeepsLoopbackAddressesSeparate(t *testing.T jw.MaxPendingRequestsPerIP = 1 oldReq := newPendingLimitRequest("127.0.0.1:1000") - oldRq := jw.NewRequest(oldReq) + oldRq := jw.newRequest(oldReq) setPendingLimitLastWrite(t, oldRq, 3600) newReq := newPendingLimitRequest("[::1]:1000") - newRq := jw.NewRequest(newReq) + newRq := jw.newRequest(newReq) if got := jw.Pending(); got != 2 { t.Fatalf("Pending() = %d, want 2", got) @@ -1371,8 +1371,8 @@ func TestJaws_MaxPendingRequestsPerIPEvictionCause(t *testing.T) { jw.MaxPendingRequestsPerIP = 1 oldReq := newPendingLimitRequest("192.0.2.1:1000") - jw.NewRequest(oldReq) - jw.NewRequest(newPendingLimitRequest("192.0.2.1:1001")) + jw.newRequest(oldReq) + jw.newRequest(newPendingLimitRequest("192.0.2.1:1001")) loggedErr := logger.next(t) if !errors.Is(loggedErr, ErrRequestCancelled) { @@ -1496,7 +1496,7 @@ func TestJaws_MaxPendingRequestsPerIPMaintenanceRemovesPendingIndex(t *testing.T defer jw.Close() jw.MaxPendingRequestsPerIP = 1 - rq := jw.NewRequest(newPendingLimitRequest("192.0.2.1:1000")) + rq := jw.newRequest(newPendingLimitRequest("192.0.2.1:1000")) setPendingLimitLastWrite(t, rq, 3600) jw.maintenance(time.Second) @@ -1642,8 +1642,8 @@ func TestJaws_DirtyExactElementAndTag(t *testing.T) { } defer jw.Close() - first := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/first", nil)) - second := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/second", nil)) + first := jw.newRequest(httptest.NewRequest(http.MethodGet, "/first", nil)) + second := jw.newRequest(httptest.NewRequest(http.MethodGet, "/second", nil)) exact := first.NewElement(&testUi{}) sharedSecond := second.NewElement(&testUi{}) shared := tag.Tag("shared") @@ -1690,7 +1690,7 @@ func TestJaws_DirtyIgnoresIneligibleElementTargets(t *testing.T) { } defer other.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) deleted := rq.NewElement(&testUi{}) jw.Dirty(deleted) jw.distributeDirt() @@ -1701,7 +1701,7 @@ func TestJaws_DirtyIgnoresIneligibleElementTargets(t *testing.T) { t.Fatalf("exact Element queued %d updates before deletion, want 1", queued) } rq.DeleteElement(deleted) - otherRq := other.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + otherRq := other.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) foreign := otherRq.NewElement(&testUi{}) var nilElement *Element @@ -2045,7 +2045,7 @@ func TestJaws_GenerateHeadHTML_AllowsExternalManualFetch(t *testing.T) { t.Fatal(err) } - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) var head strings.Builder if err = rq.HeadHTML(&head); err != nil { t.Fatal(err) @@ -2375,7 +2375,7 @@ func TestJaws_GenerateHeadHTMLConcurrentWithHeadHTML(t *testing.T) { case <-stop: return default: - rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rq := jw.newRequest(httptest.NewRequest("GET", "/", nil)) var buf bytes.Buffer if err := rq.HeadHTML(&buf); err != nil { t.Error(err) @@ -2447,7 +2447,7 @@ func TestCoverage_IDAndLookupHelpers(t *testing.T) { _ = jw.RemoveTemplateLookuper(tmpl) hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) if rq == nil { t.Fatal("expected request") } @@ -2706,7 +2706,7 @@ func TestJaws_ServeWithTimeoutFullSubscriberChannel(t *testing.T) { if err != nil { t.Fatal(err) } - rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rq := jw.newRequest(httptest.NewRequest("GET", "/", nil)) msgCh := make(chan wire.Message) // unbuffered: always full when nobody receives done := make(chan struct{}) go func() { @@ -2933,7 +2933,7 @@ func TestServeHTTP_ResponseHeaderValuesAreIndependent(t *testing.T) { serveTail := func() *httptest.ResponseRecorder { hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) req.RemoteAddr = hr.RemoteAddr w := httptest.NewRecorder() @@ -3076,7 +3076,7 @@ func TestServeHTTP_GetKey(t *testing.T) { is.Equal(w.Header()["Cache-Control"], nil) w = httptest.NewRecorder() - rq := jw.NewRequest(req) + rq := jw.newRequest(req) req = httptest.NewRequest("", "/jaws/"+rq.JawsKeyString(), nil) jw.ServeHTTP(w, req) is.Equal(w.Code, http.StatusUpgradeRequired) @@ -3090,7 +3090,7 @@ func TestServeHTTP_Noscript(t *testing.T) { defer jw.Close() w := httptest.NewRecorder() - rq := jw.NewRequest(httptest.NewRequest("", "/", nil)) + rq := jw.newRequest(httptest.NewRequest("", "/", nil)) req := httptest.NewRequest("", "/jaws/"+rq.JawsKeyString()+"/noscript", nil) jw.ServeHTTP(w, req) is.Equal(w.Code, http.StatusNoContent) @@ -3121,7 +3121,7 @@ func TestServeHTTP_UnknownKeySuffixDoesNotClaimRequest(t *testing.T) { t.Run(tt.name, func(t *testing.T) { is := newTestHelper(t) hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) key := rq.JawsKeyString() req := httptest.NewRequest(http.MethodGet, tt.path(key), nil) @@ -3146,7 +3146,7 @@ func TestServeHTTP_TailScript_UnknownSuffixDoesNotDrain(t *testing.T) { defer jw.Close() hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) rq.NewElement(&testUi{}).SetClass("cls") req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString()+"/unknown", nil) @@ -3170,7 +3170,7 @@ func TestServeHTTP_TailScript(t *testing.T) { defer jw.Close() hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) item := &testUi{} e := rq.NewElement(item) e.SetAttr("title", ``) @@ -3198,7 +3198,7 @@ func TestServeHTTP_TailScript_EndpointIsPerRequest(t *testing.T) { defer jw.Close() hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) req.RemoteAddr = hr.RemoteAddr @@ -3228,7 +3228,7 @@ func TestServeHTTP_TailScript_RejectsRecycledKey(t *testing.T) { defer jw.Close() hr := httptest.NewRequest(http.MethodGet, "/", nil) - stale := jw.NewRequest(hr) + stale := jw.newRequest(hr) stale.NewElement(&testUi{}).SetClass("stale") staleKey := stale.JawsKeyString() @@ -3236,7 +3236,7 @@ func TestServeHTTP_TailScript_RejectsRecycledKey(t *testing.T) { // reused, so rq is a distinct Request; the content check below guards that it // carries none of the finished request's queued content. jw.recycle(stale) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) rq.NewElement(&testUi{}).SetClass("fresh") // The old key was tombstoned on completion, so the lookup finds no live Request @@ -3269,7 +3269,7 @@ func TestServeHTTP_TailScript_IPMismatch(t *testing.T) { hr := httptest.NewRequest(http.MethodGet, "/", nil) hr.RemoteAddr = "203.0.113.1:1111" - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) rq.NewElement(&testUi{}).SetClass("cls") // A fetch from a different (non-loopback) IP is rejected and must not consume the @@ -3297,7 +3297,7 @@ func TestServeHTTP_TailScript_WriteError(t *testing.T) { defer jw.Close() hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) item := &testUi{} rq.NewElement(item).SetClass("cls") @@ -3319,7 +3319,7 @@ func TestJaws_cancelIfCurrent_IgnoresStaleRequest(t *testing.T) { go jw.Serve() defer jw.Close() - stale := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + stale := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) jawsKey := stale.JawsKey // The /jaws/.tail handler snapshots the Request before writing the response; the @@ -3327,7 +3327,7 @@ func TestJaws_cancelIfCurrent_IgnoresStaleRequest(t *testing.T) { // and create another request: cancelIfCurrent must cancel nothing, because the // finished Request is no longer the registered entry for its key. jw.recycle(stale) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) jw.cancelIfCurrent(jawsKey, stale, errors.New("write failed")) @@ -3524,7 +3524,7 @@ func populateBenchRequest(rq *Request, tags []any) { // render-state population and recycle path; it does not measure HTTP or WebSocket // serving. // -// The impl axis selects pooled (jw.NewRequest/recycle, which allocates a fresh +// The impl axis selects pooled (jw.newRequest/recycle, which allocates a fresh // Request but reuses its buffers from jw.requestBufferPool) versus unpooled (a // fresh &Request with freshly allocated buffers each iteration, nothing reused). // The mode axis runs the cycle single-goroutine (serial) or under @@ -3552,7 +3552,7 @@ func BenchmarkRequestLifecyclePooling(b *testing.B) { newRq func(*Jaws) *Request recycle func(*Jaws, *Request) }{ - {"impl=pooled", func(jw *Jaws) *Request { return jw.NewRequest(nil) }, func(jw *Jaws, rq *Request) { jw.recycle(rq) }}, + {"impl=pooled", func(jw *Jaws) *Request { return jw.newRequest(nil) }, func(jw *Jaws, rq *Request) { jw.recycle(rq) }}, {"impl=unpooled", newUnpooledBenchRequest, recycleUnpooledBenchRequest}, } { b.Run(impl.name, func(b *testing.B) { @@ -3584,7 +3584,7 @@ func BenchmarkRequestLifecyclePooling(b *testing.B) { // newBenchPoolJaws returns a Jaws for the Request-pool benchmarks with the per-IP // pending cap disabled. // -// All NewRequest(nil) calls share the zero client IP, so leaving the cap enabled +// All newRequest(nil) calls share the zero client IP, so leaving the cap enabled // would let eviction and the pending-slice scan skew the pooled vs unpooled delta; // disabling it for both impls keeps the comparison about allocation reuse alone. func newBenchPoolJaws(b *testing.B) (jw *Jaws) { @@ -3620,14 +3620,14 @@ func BenchmarkRequestRecycleAfterHighWater(b *testing.B) { jw := newBenchPoolJaws(b) // Grow a buffer to the high-water mark, then return it to the pool so the // empty cycles below borrow (and must not rescan) its retained capacity. - big := jw.NewRequest(nil) + big := jw.newRequest(nil) for i := 0; i < hw; i++ { big.NewElement(benchUI{n: i}) } jw.recycle(big) b.ReportAllocs() for b.Loop() { - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) jw.recycle(rq) runtime.KeepAlive(rq) } @@ -3636,7 +3636,7 @@ func BenchmarkRequestRecycleAfterHighWater(b *testing.B) { } // BenchmarkRequestClaimStartFinish measures the full lifecycle transition path — -// NewRequest (pending) -> UseRequest (claimed) -> startServe (running) -> recycle +// newRequest (pending) -> UseRequest (claimed) -> startServe (running) -> recycle // (finished) — which the create/recycle and high-water benchmarks do not exercise. // It isolates the claim/start CAS work so the lifecycle-state consolidation can be // compared before vs after; it does not run the WebSocket process loop. @@ -3645,7 +3645,7 @@ func BenchmarkRequestClaimStartFinish(b *testing.B) { r := httptest.NewRequest(http.MethodGet, "/", nil) b.ReportAllocs() for b.Loop() { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if jw.UseRequest(rq.JawsKey, r) != rq { b.Fatal("claim failed") } @@ -3709,7 +3709,7 @@ func BenchmarkSubscriptionChannels(b *testing.B) { <-doneCh }() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) b.ReportAllocs() for b.Loop() { @@ -4201,7 +4201,7 @@ var benchSink wire.WsMsg // queued, then Request.sendQueue drains them through getSendMsgs into the // WebSocket send channel, which the benchmark then empties. It guards the per-drain // cost the process loop pays (twice per loop iteration) on the outbound path. rq -// comes from jw.NewRequest(nil) for a live, never-cancelled ctx, and the channel is +// comes from jw.newRequest(nil) for a live, never-cancelled ctx, and the channel is // buffered to K so sends never block. func BenchmarkSendQueue(b *testing.B) { for _, k := range []int{8, 64, 512} { @@ -4211,7 +4211,7 @@ func BenchmarkSendQueue(b *testing.B) { b.Fatal(err) } b.Cleanup(func() { jw.Close() }) - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) msgs := make([]wire.WsMsg, k) for i := range msgs { msgs[i] = wire.WsMsg{Jid: 0, Data: "x"} @@ -4317,7 +4317,7 @@ func BenchmarkRetirePendingRequests(b *testing.B) { b.ResetTimer() for b.Loop() { for i := range requests { - requests[i] = jw.NewRequest(r) + requests[i] = jw.newRequest(r) } jw.mu.Lock() for _, rq := range requests { diff --git a/jawsboot/jawsboot_test.go b/jawsboot/jawsboot_test.go index 596d64ac..73e53e55 100644 --- a/jawsboot/jawsboot_test.go +++ b/jawsboot/jawsboot_test.go @@ -64,12 +64,12 @@ func TestJawsBoot_Setup(t *testing.T) { t.Fatal(err) } - rq := jw.NewRequest(nil) - var sb strings.Builder - if err = (ui.RequestWriter{Request: rq, Writer: &sb}).HeadHTML(); err != nil { + rr := httptest.NewRecorder() + rq := jw.NewRequest(rr, nil) + if err = (ui.RequestWriter{Request: rq, Writer: rr}).HeadHTML(); err != nil { t.Fatal(err) } - txt := sb.String() + txt := rr.Body.String() if !strings.Contains(txt, rq.JawsKeyString()) { t.Error(txt) } @@ -198,12 +198,12 @@ func TestJawsBoot_SetupNilHandleFuncGeneratesHead(t *testing.T) { t.Fatal(err) } - rq := jw.NewRequest(nil) - var sb strings.Builder - if err := (ui.RequestWriter{Request: rq, Writer: &sb}).HeadHTML(); err != nil { + rr := httptest.NewRecorder() + rq := jw.NewRequest(rr, nil) + if err := (ui.RequestWriter{Request: rq, Writer: rr}).HeadHTML(); err != nil { t.Fatal(err) } - head := sb.String() + head := rr.Body.String() for _, exp := range expected { if !strings.Contains(head, `"`+exp.uri+`"`) { t.Errorf("expected head html to include %q", exp.uri) @@ -232,12 +232,12 @@ func TestJawsBoot_SetupPrefixVariants(t *testing.T) { t.Fatal(setupErr) } - rq := jw.NewRequest(nil) - var sb strings.Builder - if err := (ui.RequestWriter{Request: rq, Writer: &sb}).HeadHTML(); err != nil { + rr := httptest.NewRecorder() + rq := jw.NewRequest(rr, nil) + if err := (ui.RequestWriter{Request: rq, Writer: rr}).HeadHTML(); err != nil { t.Fatal(err) } - head := sb.String() + head := rr.Body.String() for _, exp := range assets { wantURI := expectedJawsBootURL(tc.wantRoot, exp.ss.Name) diff --git a/jawstest/AI.md b/jawstest/AI.md index 64a5d264..0bee5001 100644 --- a/jawstest/AI.md +++ b/jawstest/AI.md @@ -10,10 +10,10 @@ production package do not acquire its `net/http/httptest` dependency. The harness reaches the loop only through the exported `jaws.Jaws.TestServe` hook; it is not a second implementation of request processing. -Keep higher-level rendering assertions in the package under test. `Recorder` is -only a sink for HTML the test explicitly renders; the harness never writes to -it. `BodyString` trims that recorded body, and `BodyHTML` trusts it because the -test itself supplied the content. +Keep higher-level rendering assertions in the package under test. `Recorder` +starts with `Cache-Control: no-store`; the harness writes no response body. +Tests may render into it. `BodyString` trims the body; `BodyHTML` treats it as +trusted test content. ## Harness lifecycle diff --git a/jawstest/jawstest.go b/jawstest/jawstest.go index 9032ed83..5242e85e 100644 --- a/jawstest/jawstest.go +++ b/jawstest/jawstest.go @@ -36,11 +36,11 @@ import ( // messages than the buffer holds without reading OutCh stalls the loop, and a // wait on DoneCh after [TestRequest.Close] then never completes. // -// Recorder is a sink for the test's own rendering, for example as the Writer -// of a ui.RequestWriter; nothing in the harness writes to it. +// Recorder starts with "Cache-Control: no-store" and records response body +// written by the test; the harness writes no body. type TestRequest struct { *jaws.Request - Recorder *httptest.ResponseRecorder // sink for the test's own rendering; the harness never writes to it + Recorder *httptest.ResponseRecorder // response recorder ReadyCh chan struct{} // closed once the processing loop is running DoneCh chan struct{} // closed once the processing loop has stopped InCh chan wire.WsMsg // send inbound WebSocket messages here @@ -91,7 +91,7 @@ func NewTestRequestWithPanic(jw *jaws.Jaws, r *http.Request, onPanic func(recove r = httptest.NewRequest(http.MethodGet, "/", nil) } rr := httptest.NewRecorder() - rq := newRequest(jw, r) + rq := newRequest(jw, rr, r) // The rq == nil guard is defensive against the newRequest seam (NewRequest loops // until a key is allocated and never returns nil in production); the claim check is // the disjunct that fails in practice. Panic rather than returning nil so the diff --git a/jawstest/jawstest_test.go b/jawstest/jawstest_test.go index 333cd1b5..caeec622 100644 --- a/jawstest/jawstest_test.go +++ b/jawstest/jawstest_test.go @@ -76,6 +76,9 @@ func TestNewTestRequest_SuccessAndClose(t *testing.T) { if tr.JawsKeyString() == "" { t.Fatal("expected a non-empty jaws key from the embedded request") } + if got := tr.Recorder.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Recorder Cache-Control = %q, want %q", got, "no-store") + } // The recorder starts empty; BodyString trims surrounding whitespace. if s := tr.BodyString(); s != "" { diff --git a/jawstest/seam_test.go b/jawstest/seam_test.go index 943d12ec..1cfa274a 100644 --- a/jawstest/seam_test.go +++ b/jawstest/seam_test.go @@ -34,8 +34,8 @@ func TestNewTestRequest_PanicsWhenClaimFails(t *testing.T) { orig := newRequest t.Cleanup(func() { newRequest = orig }) - newRequest = func(jw *jaws.Jaws, r *http.Request) *jaws.Request { - rq := orig(jw, r) + newRequest = func(jw *jaws.Jaws, w http.ResponseWriter, r *http.Request) *jaws.Request { + rq := orig(jw, w, r) jw.UseRequest(rq.JawsKey, r) // claim it before NewTestRequest can return rq } diff --git a/lib/bind/makehtmlgetter_render_test.go b/lib/bind/makehtmlgetter_render_test.go index 35406cb8..a887ec69 100644 --- a/lib/bind/makehtmlgetter_render_test.go +++ b/lib/bind/makehtmlgetter_render_test.go @@ -78,7 +78,7 @@ func newMakeHTMLGetterRequest(t *testing.T, logger jaws.Logger) *jaws.Request { } t.Cleanup(jw.Close) jw.Logger = logger - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { t.Fatal("NewRequest returned nil") } diff --git a/lib/named/namedboolarray_nil_test.go b/lib/named/namedboolarray_nil_test.go index cacea251..cf230c68 100644 --- a/lib/named/namedboolarray_nil_test.go +++ b/lib/named/namedboolarray_nil_test.go @@ -56,7 +56,7 @@ func TestBoolArrayWriteLockedDiscardsNilElements(t *testing.T) { t.Fatal(err) } t.Cleanup(jw.Close) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) elem := rq.NewElement(ui.NewSelect(bools)) var rendered strings.Builder if err = elem.JawsRender(&rendered, nil); err != nil { diff --git a/lib/named/testsupport_test.go b/lib/named/testsupport_test.go index 39dc5461..5ca3aaab 100644 --- a/lib/named/testsupport_test.go +++ b/lib/named/testsupport_test.go @@ -18,7 +18,7 @@ func newCoreRequest(t *testing.T) (*jaws.Jaws, *jaws.Request) { t.Fatal(err) } t.Cleanup(jw.Close) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { t.Fatal("nil request") } diff --git a/lib/ui/AI.md b/lib/ui/AI.md index 9823a096..488ef4cf 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -76,11 +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`, 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: +belong in `ui.Handler`, which sets `Cache-Control: no-store`. Custom page +handlers must call `jw.NewRequest(w, r)` before writing output; +`Request.HeadHTML` does not manage response headers. Static structural inclusion +should use Go's native template action: ```gotemplate {{template "partial" .Dot}} diff --git a/lib/ui/container_reuse_benchmark_test.go b/lib/ui/container_reuse_benchmark_test.go index 080dfc72..b3645069 100644 --- a/lib/ui/container_reuse_benchmark_test.go +++ b/lib/ui/container_reuse_benchmark_test.go @@ -50,7 +50,7 @@ func benchReuseRequest(b *testing.B) (*jaws.Jaws, *jaws.Request) { jw.Close() b.Fatal(err) } - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { jw.Close() b.Fatal("nil request") diff --git a/lib/ui/container_test.go b/lib/ui/container_test.go index e60661d3..3aed322e 100644 --- a/lib/ui/container_test.go +++ b/lib/ui/container_test.go @@ -496,7 +496,7 @@ func benchRequest(b *testing.B) (*jaws.Jaws, *jaws.Request) { if err != nil { b.Fatal(err) } - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { jw.Close() b.Fatal("nil request") diff --git a/lib/ui/example_test.go b/lib/ui/example_test.go index bf5d7e35..4b1fb11b 100644 --- a/lib/ui/example_test.go +++ b/lib/ui/example_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "html/template" + "net/http/httptest" "strings" "sync" @@ -74,7 +75,7 @@ func ExampleTemplate_failureBehavior() { if err = jw.AddTemplateLookuper(tmpl); err != nil { panic(err) } - rq := jw.NewRequest(nil) + rq := jw.NewRequest(httptest.NewRecorder(), nil) elem := rq.NewElement(ui.NewTemplate("div", "partial", tag.Tag("dot"))) var out bytes.Buffer diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 581b4473..54fde7c4 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -89,8 +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) + rq := h.NewRequest(w, r) sr := &statusRecorder{ResponseWriter: w} rw := RequestWriter{Request: rq, Writer: sr} // Build a fresh per-request pointer so the UI is comparable as a map key @@ -116,19 +115,16 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Handler returns an http.Handler that renders the named template. // -// The returned handler can be registered directly with a router. Each request -// results in the template being looked up through the configured template -// 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. 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. +// For each request, Handler looks up name and renders it with [With.Dot] set to +// dot. Unless the response already has a Content-Type, Handler sets it to +// "text/html; charset=utf-8" on the first write. Before rendering, Handler calls +// [jaws.Jaws.NewRequest], which replaces any existing Cache-Control value with +// "no-store". A render failure before any output uses [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; -// dot and its callbacks must support concurrent execution. +// Handler renders without a generated wrapper and does not use dot as a tag. +// Dot may be arbitrary template data. Handler reuses dot across requests; dot +// and its callbacks must support concurrent execution. func Handler(jw *jaws.Jaws, name string, dot any) http.Handler { return uiHandler{Jaws: jw, name: name, dot: dot} } diff --git a/lib/ui/http_request_benchmark_test.go b/lib/ui/http_request_benchmark_test.go index c7c16b4c..837654da 100644 --- a/lib/ui/http_request_benchmark_test.go +++ b/lib/ui/http_request_benchmark_test.go @@ -68,7 +68,7 @@ func newBenchmarkStaticHTMLHandler(_ *testing.B, jw *jaws.Jaws) http.Handler { } func (h benchmarkStaticHTMLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - rq := h.jw.NewRequest(r) + rq := h.jw.NewRequest(w, r) _, _ = io.WriteString(w, "Static") _ = rq.HeadHTML(w) _, _ = io.WriteString(w, `

Static HTML

A plain page with JaWS head and tail hooks.

`) diff --git a/lib/ui/jsvar_benchmark_test.go b/lib/ui/jsvar_benchmark_test.go index bb0cba32..fd0aeb1f 100644 --- a/lib/ui/jsvar_benchmark_test.go +++ b/lib/ui/jsvar_benchmark_test.go @@ -34,7 +34,7 @@ func newBenchmarkJsVar(b *testing.B) (jsvar *JsVar[benchmarkJsVarState], elem *j b.Cleanup(jw.Close) go jw.Serve() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { b.Fatal("nil request") } @@ -79,7 +79,7 @@ func BenchmarkJsVarPathSetterMutation(b *testing.B) { b.Fatal(err) } b.Cleanup(jw.Close) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { b.Fatal("nil request") } @@ -123,7 +123,7 @@ func BenchmarkJsVarClientWrite(b *testing.B) { } b.Cleanup(jw.Close) go jw.Serve() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { b.Fatal("nil request") } diff --git a/lib/ui/jsvar_panic_test.go b/lib/ui/jsvar_panic_test.go index 3ccb9d8a..3043f4af 100644 --- a/lib/ui/jsvar_panic_test.go +++ b/lib/ui/jsvar_panic_test.go @@ -88,7 +88,7 @@ func TestJsVarPathSetterPanicReleasesValueLock(t *testing.T) { mux := http.NewServeMux() mux.Handle("GET /jaws/", jw) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { - rendered := renderedJsVar{rq: jw.NewRequest(r)} + rendered := renderedJsVar{rq: jw.NewRequest(w, r)} rw := RequestWriter{Request: rendered.rq, Writer: w} if rendered.err = rw.HeadHTML(); rendered.err == nil { rendered.err = rw.JsVar("panicSafe", jsvar) diff --git a/lib/ui/template_handler_test.go b/lib/ui/template_handler_test.go index a6fa439d..2818b1d4 100644 --- a/lib/ui/template_handler_test.go +++ b/lib/ui/template_handler_test.go @@ -460,7 +460,7 @@ func TestHandler_TemplateWritesKeepPendingRequestFresh(t *testing.T) { secondReq := httptest.NewRequest(http.MethodGet, "/second", nil) secondReq.RemoteAddr = req.RemoteAddr - _ = jw.NewRequest(secondReq) + _ = jw.NewRequest(httptest.NewRecorder(), secondReq) gotKey := first.JawsKeyString() gotInitial := first.Initial() diff --git a/lib/ui/template_owned_benchmark_test.go b/lib/ui/template_owned_benchmark_test.go index 9d7dc1a8..30837f11 100644 --- a/lib/ui/template_owned_benchmark_test.go +++ b/lib/ui/template_owned_benchmark_test.go @@ -42,7 +42,7 @@ func benchOwnedFixture(b *testing.B, nested int) (jw *jaws.Jaws, update func()) jw.Close() b.Fatal(err) } - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { jw.Close() b.Fatal("nil request") diff --git a/lib/ui/testhelpers_test.go b/lib/ui/testhelpers_test.go index cca7a1dc..d5fe294a 100644 --- a/lib/ui/testhelpers_test.go +++ b/lib/ui/testhelpers_test.go @@ -105,7 +105,7 @@ func newConfiguredCoreRequest(t *testing.T, configure func(*jaws.Jaws)) (*jaws.J if configure != nil { configure(jw) } - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.NewRequest(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) if rq == nil { t.Fatal("nil request") } @@ -130,7 +130,7 @@ func newCoreSessionBoundRequest(t *testing.T) (*jaws.Jaws, *jaws.Request) { if sess := jw.NewSession(rr, hr); sess == nil { t.Fatal("expected session") } - rq := jw.NewRequest(hr) + rq := jw.NewRequest(rr, hr) if rq == nil { t.Fatal("expected request") } diff --git a/request.go b/request.go index c84c51c5..d835756a 100644 --- a/request.go +++ b/request.go @@ -454,10 +454,8 @@ 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. +// HeadHTML does not modify response headers. [Jaws.NewRequest] sets +// "Cache-Control: no-store" when it creates the Request. func (rq *Request) HeadHTML(w io.Writer) (err error) { rq.mu.RLock() jawsKey := rq.JawsKey diff --git a/request_identity_test.go b/request_identity_test.go index 43ffcacc..e299ea72 100644 --- a/request_identity_test.go +++ b/request_identity_test.go @@ -39,7 +39,7 @@ func TestEarlyCallbackPreservesInitialRenderIdentity(t *testing.T) { initial := httptest.NewRequest(http.MethodGet, "/", nil) initial.RemoteAddr = "192.0.2.1:1000" - rq := jw.NewRequest(initial) + rq := jw.newRequest(initial) wantKey := rq.JawsKeyString() if wantKey == "" { t.Fatal("NewRequest returned an empty key") @@ -80,7 +80,7 @@ func TestRequestLateCancelDoesNotReachNextConnection(t *testing.T) { return r } - finished := jw.NewRequest(newInitialRequest("/first")) + finished := jw.newRequest(newInitialRequest("/first")) finishedKey := finished.JawsKey ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() @@ -95,7 +95,7 @@ func TestRequestLateCancelDoesNotReachNextConnection(t *testing.T) { } waitForRequestCount(t, jw, 0, time.Second) - replacement := jw.NewRequest(newInitialRequest("/second")) + replacement := jw.newRequest(newInitialRequest("/second")) defer jw.recycle(replacement) if replacement == finished { t.Fatal("NewRequest reused the finished Request identity") @@ -125,7 +125,7 @@ func TestRequestFinishDoesNotPanicOnContinuedRender(t *testing.T) { } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) rq.Tag(rq.NewElement(&testUi{}), tag.Tag("live")) // Finish the Request out from under the still-running initial renderer, as a racy @@ -139,7 +139,7 @@ func TestRequestFinishDoesNotPanicOnContinuedRender(t *testing.T) { rq.Tag(late, tag.Tag("late")) rq.Dirty(tag.Tag("live")) - other := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/next", nil)) + other := jw.newRequest(httptest.NewRequest(http.MethodGet, "/next", nil)) defer jw.recycle(other) if other == rq { t.Fatal("NewRequest reused a finished Request identity") @@ -164,7 +164,7 @@ func TestRequestFinishConcurrentWithRenderIsRaceFree(t *testing.T) { const n = 200 var wg sync.WaitGroup for range n { - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) wg.Add(2) go func() { defer wg.Done() @@ -192,7 +192,7 @@ func TestFinishDoesNotResetJidCounter(t *testing.T) { } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) rq.NewElement(&testUi{}) last := rq.NewElement(&testUi{}).Jid() @@ -217,7 +217,7 @@ func TestRecycleQueueRaceDoesNotLeak(t *testing.T) { const n = 300 for range n { - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) var wg sync.WaitGroup wg.Add(2) go func() { @@ -248,7 +248,7 @@ func TestNoscriptDuringLiveRenderRecordsJavascriptDisabled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() initial := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) - rq := jw.NewRequest(initial) + rq := jw.newRequest(initial) w := httptest.NewRecorder() probe := httptest.NewRequest(http.MethodGet, "/jaws/"+rq.JawsKeyString()+"/noscript", nil) @@ -284,13 +284,13 @@ func TestRequestRecycledKeyNotReusedWhileReachable(t *testing.T) { jw.kg = bufio.NewReader(bytes.NewReader(stream)) jw.mu.Unlock() - rq1 := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq1 := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) if rq1.JawsKey != k { t.Fatalf("rq1 key = %v, want forced %v", rq1.JawsKey, k) } jw.recycle(rq1) - rq2 := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq2 := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) defer jw.recycle(rq2) if rq2.JawsKey == k { t.Fatal("recycled key K was reassigned while the finished Request is still reachable") diff --git a/request_state_test.go b/request_state_test.go index b296bad0..09e73631 100644 --- a/request_state_test.go +++ b/request_state_test.go @@ -42,7 +42,7 @@ func TestRequestStateTransitions(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/", nil) t.Run("pending->claimed->running->finished", func(t *testing.T) { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if got := rq.loadState(); got != reqPending { t.Fatalf("after NewRequest = %v, want pending", got) } @@ -65,7 +65,7 @@ func TestRequestStateTransitions(t *testing.T) { }) t.Run("recycle_never_claimed_pending->finished", func(t *testing.T) { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if got := rq.loadState(); got != reqPending { t.Fatalf("state = %v, want pending", got) } @@ -76,7 +76,7 @@ func TestRequestStateTransitions(t *testing.T) { }) t.Run("retire_claimed_not_running->finished", func(t *testing.T) { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if jw.UseRequest(rq.JawsKey, r) != rq { t.Fatal("claim failed") } @@ -92,7 +92,7 @@ func TestRequestStateTransitions(t *testing.T) { }) t.Run("double_claim_and_double_startServe_rejected", func(t *testing.T) { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if jw.UseRequest(rq.JawsKey, r) != rq { t.Fatal("first claim failed") } @@ -115,7 +115,7 @@ func TestRequestStateTransitions(t *testing.T) { }) t.Run("terminal_stays_finished", func(t *testing.T) { - rq := jw.NewRequest(r) + rq := jw.newRequest(r) jw.recycle(rq) if got := rq.loadState(); got != reqFinished { t.Fatalf("state = %v, want finished", got) @@ -178,7 +178,7 @@ func TestClaimPostCloseReturnsCauseNotAlreadyClaimed(t *testing.T) { jw.Close() r := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if got := rq.loadState(); got != reqUnclaimable { t.Fatalf("post-Close state = %v, want unclaimable", got) } @@ -207,7 +207,7 @@ func TestServe_DuplicatePanicsWithoutDisturbingFirst(t *testing.T) { waitForServeLoop(t, jw) r := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if jw.UseRequest(rq.JawsKey, r) != rq { t.Fatal("claim failed") } @@ -254,7 +254,7 @@ func TestServe_CloseRaceIsSafe(t *testing.T) { go jw.Serve() waitForServeLoop(t, jw) r := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(r) + rq := jw.newRequest(r) if jw.UseRequest(rq.JawsKey, r) != rq { t.Fatal("claim failed") } diff --git a/request_test.go b/request_test.go index 38931689..1f912058 100644 --- a/request_test.go +++ b/request_test.go @@ -247,7 +247,7 @@ func TestRequest_wantMessage_RejectsFinishedRequest(t *testing.T) { go jw.Serve() defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) staleKey := rq.JawsKey is.True(rq.wantMessage(&wire.Message{Dest: staleKey})) @@ -258,7 +258,7 @@ func TestRequest_wantMessage_RejectsFinishedRequest(t *testing.T) { // A later client gets a distinct Request with a distinct key. It matches only its // own key, and the finished Request's key is not reassigned to it (rq is still // reachable here, so its key stays reserved). - replacement := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/next", nil)) + replacement := jw.newRequest(httptest.NewRequest(http.MethodGet, "/next", nil)) defer jw.recycle(replacement) is.True(replacement != rq) is.True(replacement.wantMessage(&wire.Message{Dest: replacement.JawsKey})) @@ -269,7 +269,7 @@ func TestRequest_HeadHTML(t *testing.T) { is := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) var sb strings.Builder @@ -292,7 +292,7 @@ func TestRequest_HeadHTML_DebugMeta(t *testing.T) { if err = jw.GenerateHeadHTML(); err != nil { t.Fatal(err) } - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) var sb strings.Builder @@ -309,7 +309,7 @@ func TestRequestWriter_TailHTML(t *testing.T) { th := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) item := &testUi{} e := rq.NewElement(item) @@ -342,7 +342,7 @@ func TestRequest_writeTailScript_EscapesScriptClose(t *testing.T) { th := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) item := &testUi{} e := rq.NewElement(item) @@ -364,7 +364,7 @@ func TestRequest_writeTailScript_QuotesAstralAndLineSeparators(t *testing.T) { th := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) item := &testUi{} e := rq.NewElement(item) @@ -398,7 +398,7 @@ func TestRequest_writeTailScript_PreservesNonAttrMessages(t *testing.T) { th := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) item := &testUi{} e := rq.NewElement(item) @@ -431,7 +431,7 @@ func TestRequest_writeTailScript_RemoveAttrAndClass(t *testing.T) { th := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) item := &testUi{} e := rq.NewElement(item) @@ -462,7 +462,7 @@ func TestRequest_writeTailScript_IsolatesEachFixup(t *testing.T) { th := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) e1 := rq.NewElement(&testUi{}) e2 := rq.NewElement(&testUi{}) @@ -502,7 +502,7 @@ func TestRequest_TailScriptConcurrentWithRecycle(t *testing.T) { const n = 300 var wg sync.WaitGroup for i := 0; i < n; i++ { - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) e := rq.NewElement(&testUi{}) e.SetAttr("hidden", "yes") e.SetClass("cls") @@ -531,7 +531,7 @@ func TestRequest_wantMessageConcurrentWithRecycle(t *testing.T) { const n = 300 var wg sync.WaitGroup for i := 0; i < n; i++ { - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) staleKey := rq.JawsKey wg.Add(2) go func() { @@ -586,7 +586,7 @@ func TestRequest_SetContext_NilPanics(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) // No Logger is configured, so reportMisuse panics in both debug and production. @@ -696,7 +696,7 @@ func TestRequest_SetContextRegistersAfterFuncOutsideLock(t *testing.T) { if err != nil { t.Fatal(err) } - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) observed := make(chan context.Context, 1) custom := &deferredAfterContext{ Context: rq.Context(), @@ -750,7 +750,7 @@ func TestRequest_SetContextDelayedCallbackDoesNotCancelNextRequest(t *testing.T) } defer jw.Close() - first := jw.NewRequest(nil) + first := jw.newRequest(nil) callbackCalled := make(chan struct{}, 1) var callbackArmed atomic.Bool first.mu.Lock() @@ -773,7 +773,7 @@ func TestRequest_SetContextDelayedCallbackDoesNotCancelNextRequest(t *testing.T) // Requests keep a stable identity and are never reused, so NewRequest returns a // distinct Request. A delayed SetContext callback still bound to first must not // reach this next Request's context. - second := jw.NewRequest(nil) + second := jw.newRequest(nil) if second == first { t.Fatal("NewRequest reused a finished Request identity") } @@ -999,7 +999,7 @@ func TestRequest_ClaimRefreshesLastWriteAndStartServeGuards(t *testing.T) { } defer jw.Close() hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) // Simulate a page that rendered long ago (idle) before its WebSocket connects. rq.lastWriteSeconds.Store(jw.runtimeSeconds.Load() - 3600) @@ -1052,7 +1052,7 @@ func TestRequest_ClaimRejectsCanceledRequest(t *testing.T) { defer jw.Close() hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) wantCause := errors.New("cancel before claim") rq.Cancel(wantCause) @@ -1258,7 +1258,7 @@ func TestRequest_ProducersSkipRecycled(t *testing.T) { jw, _ := New() defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) jw.recycle(rq) th.Equal(rq.destKey(), key.Key(0)) @@ -1279,7 +1279,7 @@ func TestRequest_Cancel(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) rq.Cancel(errors.New("abort")) if rq.Context().Err() == nil { t.Error("expected context to be cancelled after Cancel") @@ -1339,7 +1339,7 @@ func TestRequest_Redirect_unsafeRefused(t *testing.T) { defer jw.Close() logger := &captureErrorLogger{} jw.Logger = logger - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) rq.Redirect("javascript:alert(1)") loggedErr := logger.next(t) if !strings.Contains(loggedErr.Error(), "refusing unsafe redirect") { @@ -1697,7 +1697,7 @@ func TestRequest_validateWebSocketOrigin_MatchesInitialRequestOrigin(t *testing. if strings.EqualFold(initialURL.Scheme, "https") { initial.TLS = &tls.ConnectionState{} } - rq := jw.NewRequest(initial) + rq := jw.newRequest(initial) defer jw.recycle(rq) wsReq := httptest.NewRequest(http.MethodGet, "/jaws/"+rq.JawsKeyString(), nil) @@ -1724,9 +1724,9 @@ func TestRequest_validateWebSocketOrigin_NoInitialFailsClosed(t *testing.T) { } defer jw.Close() - // A Request can be constructed without an initial HTTP request (NewRequest(nil)). + // A Request can be constructed without an initial HTTP request (newRequest(nil)). // Origin validation must then fail closed rather than accepting any Origin. - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) wsReq := httptest.NewRequest(http.MethodGet, "/jaws/"+rq.JawsKeyString(), nil) @@ -2423,7 +2423,7 @@ func TestRequest_getSendMsgsKeepsOrderFromDeletedElement(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) childA := rq.NewElement(&testUi{}) childB := rq.NewElement(&testUi{}) @@ -2453,7 +2453,7 @@ func TestRequest_getSendMsgsDropsCallFromDeletedElement(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) elem := rq.NewElement(&testUi{}) elem.JsCall("fn", "{}") @@ -2476,7 +2476,7 @@ func TestRequest_queueEventOverloadCancels(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) first := rq.NewElement(&testUi{}) @@ -2532,7 +2532,7 @@ func TestRequest_handleIncomingSkipsEventsWithoutTargets(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) full := make(chan eventFnCall, 1) @@ -2553,7 +2553,7 @@ func TestRequest_renderDebugLocked(t *testing.T) { is := newTestHelper(t) jw, _ := New() defer jw.Close() - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) defer jw.recycle(rq) tss := &testUi{} @@ -2591,7 +2591,7 @@ func TestCoverage_PendingSubscribeMaintenanceAndParse(t *testing.T) { defer jw.Close() hr := httptest.NewRequest("GET", "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) total, active := jw.RequestCounts() if total != 1 || active != 0 { t.Fatalf("RequestCounts() = %d, %d, want 1, 0", total, active) @@ -2668,7 +2668,7 @@ func TestCoverage_RequestMaintenanceClaimAndErrors(t *testing.T) { defer jw.Close() hr := httptest.NewRequest("GET", "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) if err := rq.claim(hr); err != nil { t.Fatal(err) } @@ -2678,7 +2678,7 @@ func TestCoverage_RequestMaintenanceClaimAndErrors(t *testing.T) { hrA := httptest.NewRequest("GET", "/", nil) hrA.RemoteAddr = "1.2.3.4:1234" - rqA := jw.NewRequest(hrA) + rqA := jw.newRequest(hrA) hrB := httptest.NewRequest("GET", "/", nil) hrB.RemoteAddr = "2.2.2.2:4321" if err := rqA.claim(hrB); !errors.Is(err, ErrWebSocketIPMismatch) { @@ -2688,22 +2688,22 @@ func TestCoverage_RequestMaintenanceClaimAndErrors(t *testing.T) { } nowSeconds := jw.runtimeSeconds.Load() - rqM := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rqM := jw.newRequest(httptest.NewRequest("GET", "/", nil)) rqM.lastWriteSeconds.Store(nowSeconds - 3600) if expired, _ := rqM.maintenance(nowSeconds, time.Second); !expired { t.Fatal("expected maintenance timeout") } - rqR := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rqR := jw.newRequest(httptest.NewRequest("GET", "/", nil)) rqR.MarkWritten() if expired, _ := rqR.maintenance(jw.runtimeSeconds.Load(), time.Hour); expired { t.Fatal("a freshly written request must not be idle-expired") } - rqC := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rqC := jw.newRequest(httptest.NewRequest("GET", "/", nil)) rqC.cancel(errors.New("cancelled")) if expired, _ := rqC.maintenance(jw.runtimeSeconds.Load(), time.Hour); !expired { t.Fatal("expected maintenance cancellation") } - rqOK := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rqOK := jw.newRequest(httptest.NewRequest("GET", "/", nil)) rqOK.MarkWritten() if expired, _ := rqOK.maintenance(jw.runtimeSeconds.Load(), time.Hour); expired { t.Fatal("expected maintenance keepalive") @@ -2737,7 +2737,7 @@ func TestNewRequest_SeedsLastWriteFromLiveElapsed(t *testing.T) { // started, so runtimeSeconds is never advanced by its maintenance loop. jw.created = time.Now().Add(-time.Hour) - rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rq := jw.newRequest(httptest.NewRequest("GET", "/", nil)) // The seed must reflect true elapsed time (~3600s), not the stale zero that // runtimeSeconds still holds before Serve seeds it. @@ -2761,7 +2761,7 @@ func TestCoverage_RequestProcessHTTPDoneAndBroadcastDone(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() hr := httptest.NewRequest("GET", "/", nil).WithContext(ctx) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) if err := rq.claim(hr); err != nil { t.Fatal(err) } @@ -2798,7 +2798,7 @@ func TestRequestRecycle_StaleElementIsInert(t *testing.T) { } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rq := jw.newRequest(httptest.NewRequest("GET", "/", nil)) elem := rq.NewElement(testDivWidget{inner: "x"}) rq.Tag(elem, tag.Tag("stale")) jawsKey := rq.JawsKey @@ -3242,7 +3242,7 @@ func newTestServerWithSession(t *testing.T, withSession bool, logger Logger, deb if withSession { sess = jw.NewSession(rr, hr) } - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) if rq != jw.UseRequest(rq.JawsKey, hr) { panic("UseRequest failed") } @@ -3356,7 +3356,7 @@ func TestWS_UpgradeRequired(t *testing.T) { defer jw.Close() w := httptest.NewRecorder() hr := httptest.NewRequest("", "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) jw.UseRequest(rq.JawsKey, hr) req := httptest.NewRequest("", "/jaws/"+rq.JawsKeyString(), nil) rq.ServeHTTP(w, req) @@ -3370,7 +3370,7 @@ func TestWS_UnclaimedRequestIsGone(t *testing.T) { defer jw.Close() w := httptest.NewRecorder() hr := httptest.NewRequest("", "/", nil) - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) defer jw.recycle(rq) // UseRequest is deliberately not called, so the Request stays unclaimed and // startServe() returns false; ServeHTTP must surface an explicit error @@ -3594,7 +3594,7 @@ func TestWS_AutoSessionDoesNotCreateAfterJawsClose(t *testing.T) { } jw.AutoSession = true request := httptest.NewRequest(http.MethodGet, "http://example.test/jaws/", nil) - rq := jw.NewRequest(request) + rq := jw.newRequest(request) if got := jw.UseRequest(rq.JawsKey, request); got != rq { t.Fatalf("UseRequest() = %p, want %p", got, rq) } @@ -3928,7 +3928,7 @@ func TestWS_ConnectFnFailureDoesNotBlockOnNonReadingPeer(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), testTimeout) defer cancel() initial := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) - rq := jw.NewRequest(initial) + rq := jw.newRequest(initial) if got := jw.UseRequest(rq.JawsKey, initial); got != rq { t.Fatalf("UseRequest() = %v, want %v", got, rq) } @@ -4037,7 +4037,7 @@ func TestWS_ConnectFnSubscriptionCleanup(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), testTimeout) defer cancel() initial := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) - rq := jw.NewRequest(initial) + rq := jw.newRequest(initial) if got := jw.UseRequest(rq.JawsKey, initial); got != rq { t.Fatalf("UseRequest() = %v, want %v", got, rq) } @@ -4789,7 +4789,7 @@ func TestRequest_JawsKeyReadsAreLockedDuringRecycle(t *testing.T) { } t.Cleanup(jw.Close) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) const iterations = 2000 var wg sync.WaitGroup diff --git a/requestpool.go b/requestpool.go index 3ec2d28a..7b2c0d1f 100644 --- a/requestpool.go +++ b/requestpool.go @@ -26,12 +26,15 @@ import ( // While the [Jaws] instance is open, the returned Request is pending until it is // claimed or retired. // -// Call this as soon as you start processing an HTML request, and store the -// returned [Request] pointer so it can be used while constructing the HTML -// response in order to register the JaWS IDs you use in the response, and -// use its [Request.JawsKey] when sending the JavaScript portion of the reply. -// Do not retain the pointer beyond the initial HTTP handling and rendering; see -// [Request]. +// NewRequest replaces w's Cache-Control header with "no-store". Call it with +// the response writer before writing its headers or body. Calling it after the +// response is committed does not change the sent headers. +// +// Use the returned [Request] while rendering the initial response to register +// JaWS IDs and write [Request.HeadHTML]. Do not retain it after initial request +// handling and rendering; see [Request]. +// +// If r is nil, the Request has no initial request, client address, or [Session]. // // [Jaws.ServeWithTimeout] periodically retires idle Requests before WebSocket // processing starts; [Jaws.Serve] uses [DefaultWebSocketTimeout]. @@ -52,7 +55,14 @@ import ( // // It panics if the [crypto/rand.Reader] captured by [New] returns an error while // generating the request key. Go's default reader does not return errors. -func (jw *Jaws) NewRequest(r *http.Request) (rq *Request) { +func (jw *Jaws) NewRequest(w http.ResponseWriter, r *http.Request) *Request { + // Page metadata carries a one-use Request key. Replaying it from an HTTP + // cache reuses the consumed key and prevents another WebSocket connection. + w.Header().Set("Cache-Control", headerCacheControlNoStore) + return jw.newRequest(r) +} + +func (jw *Jaws) newRequest(r *http.Request) (rq *Request) { remoteIP := jw.clientIP(r) func() { @@ -234,7 +244,7 @@ func (jw *Jaws) UseRequest(jawsKey key.Key, r *http.Request) (rq *Request) { // getRequestLocked allocates a fresh Request identity for jawsKey, borrowing // reusable storage from jw.requestBufferPool. remoteIP is the already-resolved -// client IP for r (see NewRequest, the sole caller), passed in to avoid recomputing +// client IP for r (see newRequest, the sole caller), passed in to avoid recomputing // jw.clientIP(r). registered is false after Jaws.Close, when the canceled Request // is returned without being registered in jw.requests. Caller must hold jw.mu. func (jw *Jaws) getRequestLocked(jawsKey key.Key, r *http.Request, remoteIP netip.Addr, registered bool) (rq *Request) { diff --git a/requestpool_header_test.go b/requestpool_header_test.go new file mode 100644 index 00000000..47b9602d --- /dev/null +++ b/requestpool_header_test.go @@ -0,0 +1,30 @@ +package jaws + +import ( + "net/http/httptest" + "testing" +) + +func TestNewRequest_SetsCacheControlBeforeHeadHTML(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + go jw.Serve() + waitForServeLoop(t, jw) + + w := httptest.NewRecorder() + w.Header().Add("Cache-Control", "public") + w.Header().Add("Cache-Control", "max-age=3600") + rq := jw.NewRequest(w, nil) + if _, err := w.Write([]byte("")); err != nil { + t.Fatal(err) + } + if err := rq.HeadHTML(w); err != nil { + t.Fatal(err) + } + if got := w.Result().Header.Values("Cache-Control"); len(got) != 1 || got[0] != "no-store" { + t.Fatalf("Cache-Control = %q, want [%q]", got, "no-store") + } +} diff --git a/scripts/exercise-request-dest-reuse.mjs b/scripts/exercise-request-dest-reuse.mjs index ab5414ef..fae206d4 100644 --- a/scripts/exercise-request-dest-reuse.mjs +++ b/scripts/exercise-request-dest-reuse.mjs @@ -80,7 +80,7 @@ func main() { fmt.Fprintln(w, triggerCount.Load()) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - rq := jw.NewRequest(r) + rq := jw.NewRequest(w, r) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = io.WriteString(w, "") if err := rq.HeadHTML(w); err != nil { diff --git a/serve_test.go b/serve_test.go index 63eef15c..ba830095 100644 --- a/serve_test.go +++ b/serve_test.go @@ -104,7 +104,7 @@ func TestJaws_ServePanicDoesNotWaitForOpenLoggerQueue(t *testing.T) { }() waitForServeLoop(t, jw) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) tagValue := panickingServeTag{} elem := rq.NewElement(&testUi{}) rq.TagExpanded(elem, []any{tagValue}) @@ -164,7 +164,7 @@ func TestJaws_ServePanicDoesNotWaitForClosingLoggerQueue(t *testing.T) { stringStarted := make(chan struct{}, 1) tagValue := panickingServeTag{started: stringStarted, release: stringRelease} - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) elem := rq.NewElement(&testUi{}) rq.Tag(elem, tagValue) msgCh := make(chan wire.Message) @@ -242,7 +242,7 @@ func TestJaws_ServeMaintenanceLoggerCanBroadcastRepeatedly(t *testing.T) { }() waitForServeLoop(t, jw) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) // Maintenance uses whole-second samples and expires only after the timeout. time.Sleep(3 * time.Second) synctest.Wait() @@ -286,7 +286,7 @@ func TestJaws_ServeOverloadLoggerCanBroadcastRepeatedly(t *testing.T) { }() waitForServeLoop(t, jw) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) msgCh := make(chan wire.Message) jw.subCh <- subscription{msgCh: msgCh, rq: rq} waitForServeLoop(t, jw) @@ -323,7 +323,7 @@ func TestJaws_MaintenanceRetiresExpiredRequestOnce(t *testing.T) { logger := &maintenanceTestLogger{} jw.Logger = logger initial := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(initial) + rq := jw.newRequest(initial) key := rq.JawsKey jw.runtimeSeconds.Store(rq.lastWriteSeconds.Load() + 2) diff --git a/session_test.go b/session_test.go index fac36666..d7f78ec3 100644 --- a/session_test.go +++ b/session_test.go @@ -347,7 +347,7 @@ func TestSessionMiddleware_CloseDuringResponseHeader(t *testing.T) { h := jw.SessionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got.handlerCalled = true got.requestCookies = r.Cookies() - got.rq = jw.NewRequest(r) + got.rq = jw.newRequest(r) w.WriteHeader(http.StatusNoContent) })) h.ServeHTTP(rw, hr) @@ -418,7 +418,7 @@ func TestSession_NewSessionReplacesDuplicateCookieSessions(t *testing.T) { t.Helper() r := makeRequest(remoteAddr) r.AddCookie(cookie) - if rq = jw.NewRequest(r); rq.Session() != want { + if rq = jw.newRequest(r); rq.Session() != want { t.Fatalf("attached request session = %p, want %p", rq.Session(), want) } return @@ -460,7 +460,7 @@ func TestSession_NewSessionReplacesDuplicateCookieSessions(t *testing.T) { if got := jw.GetSession(r); got != fresh { t.Fatalf("GetSession() = %p, want fresh session %p", got, fresh) } - if got := jw.NewRequest(r).Session(); got != fresh { + if got := jw.newRequest(r).Session(); got != fresh { t.Fatalf("NewRequest().Session() = %p, want fresh session %p", got, fresh) } for _, old := range []struct { @@ -642,7 +642,7 @@ func TestSession_Use(t *testing.T) { var sb strings.Builder sess := jw.GetSession(r) - rq := jw.NewRequest(r).Writer(&sb) + rq := jw.newRequest(r).Writer(&sb) if sess != rq.Session() { t.Error(sess) } @@ -793,11 +793,11 @@ func TestSession_Broadcast(t *testing.T) { t.Fatal("expected session") } - rq1 := jw.NewRequest(hr) + rq1 := jw.newRequest(hr) hr2 := httptest.NewRequest(http.MethodGet, "/2", nil) hr2.RemoteAddr = hr.RemoteAddr hr2.AddCookie(sess.Cookie()) - rq2 := jw.NewRequest(hr2) + rq2 := jw.newRequest(hr2) if got := rq1.Session(); got != sess { t.Fatalf("request 1 session mismatch: %v", got) @@ -857,7 +857,7 @@ func TestSession_ProducersSkipRecycled(t *testing.T) { sess := jw.NewSession(rr, hr) th.True(sess != nil) - live := jw.NewRequest(hr) + live := jw.newRequest(hr) th.True(live.Session() == sess) // Session methods snapshot sess.requests under sess.mu, then process the snapshot @@ -941,8 +941,8 @@ func TestSessionCloseDoesNotReachLaterRequest(t *testing.T) { sessionHTTP := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) sess := jw.NewSession(httptest.NewRecorder(), sessionHTTP) - first := jw.NewRequest(sessionHTTP) - stale := jw.NewRequest(sessionHTTP) + first := jw.newRequest(sessionHTTP) + stale := jw.newRequest(sessionHTTP) if first.Session() != sess || stale.Session() != sess { t.Fatal("test requests did not attach to the session") } @@ -1001,7 +1001,7 @@ func TestSessionCloseDoesNotReachLaterRequest(t *testing.T) { defer server.Close() unrelatedHTTP := httptest.NewRequest(http.MethodGet, server.URL+"/unrelated", nil) unrelatedHTTP.RemoteAddr = "127.0.0.1:2000" - unrelated := jw.NewRequest(unrelatedHTTP) + unrelated := jw.newRequest(unrelatedHTTP) if unrelated == stale { t.Fatal("later client reused stale Request identity") } @@ -1079,7 +1079,7 @@ func TestSessionCloseReloadsAssociatedPendingRequest(t *testing.T) { sessionHTTP := httptest.NewRequest(http.MethodGet, server.URL+"/", nil) sessionHTTP.RemoteAddr = "127.0.0.1:1" sess := jw.NewSession(httptest.NewRecorder(), sessionHTTP) - target := jw.NewRequest(sessionHTTP) + target := jw.newRequest(sessionHTTP) if target.Session() != sess { t.Fatal("target Request was not associated with the session") } @@ -1091,7 +1091,7 @@ func TestSessionCloseReloadsAssociatedPendingRequest(t *testing.T) { // probe against the serve loop: broadcasts are processed in order. controlHTTP := httptest.NewRequest(http.MethodGet, server.URL+"/", nil) controlHTTP.RemoteAddr = "127.0.0.2:1" - control := jw.NewRequest(controlHTTP) + control := jw.newRequest(controlHTTP) if control.Session() != nil { t.Fatal("control Request must not share the session") } @@ -1189,7 +1189,7 @@ func TestSessionCloseReloadsConnectedRequestExactlyOnce(t *testing.T) { sessionHTTP := httptest.NewRequest(http.MethodGet, server.URL+"/", nil) sessionHTTP.RemoteAddr = "127.0.0.1:1" sess := jw.NewSession(httptest.NewRecorder(), sessionHTTP) - rq := jw.NewRequest(sessionHTTP) + rq := jw.newRequest(sessionHTTP) if rq.Session() != sess { t.Fatal("Request was not associated with the session") } @@ -1259,7 +1259,7 @@ func TestServeKeyTargetedUpdateFailFast(t *testing.T) { // A drained control subscription proves a broadcast was processed: broadcasts // are ordered, so once its marker arrives every earlier broadcast is handled. - control := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + control := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) controlCh := jw.subscribe(control, 32) if controlCh == nil { t.Fatal("control subscription failed") @@ -1296,7 +1296,7 @@ func TestServeKeyTargetedUpdateFailFast(t *testing.T) { // A nil-destination Update is the coalescible dirty-render tick: overflowing it // must neither cancel the Request nor kill its subscription. - dropRq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + dropRq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) dropCh := jw.subscribe(dropRq, 1) // not drained during the overflow below if dropCh == nil { t.Fatal("drop subscription failed") @@ -1337,7 +1337,7 @@ func TestServeKeyTargetedUpdateFailFast(t *testing.T) { // A tag-targeted Update is one-shot (no periodic re-send), so overflow must // fail-fast. - tagRq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + tagRq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) tagRq.NewElement(&testUi{}).Tag(tag.Tag("overload-tag")) if jw.subscribe(tagRq, 1) == nil { // never drained t.Fatal("tag subscription failed") @@ -1349,7 +1349,7 @@ func TestServeKeyTargetedUpdateFailFast(t *testing.T) { awaitCancel("tag-targeted Update", tagRq) // A key-targeted Update is the Session.Close wake-up: overflow must fail-fast. - wakeRq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + wakeRq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) if jw.subscribe(wakeRq, 1) == nil { // never drained t.Fatal("wake subscription failed") } @@ -1440,7 +1440,7 @@ func TestSession_Delete(t *testing.T) { t.Error(x) } - rq2 := ts.jw.NewRequest(hr2) + rq2 := ts.jw.newRequest(hr2) if x := rq2.Session(); x != ts.sess { t.Error(x) } @@ -1577,7 +1577,7 @@ func TestSession_Cleanup(t *testing.T) { t.Fatal(x) } - r1 := jw.NewRequest(hr) + r1 := jw.newRequest(hr) if x := sess; x != r1.Session() { t.Error(x) } @@ -1622,7 +1622,7 @@ func TestSession_UnclaimedRequestRecycleKeepsGraceDeadline(t *testing.T) { if sess == nil { t.Fatal("expected session") } - r1 := jw.NewRequest(hr) + r1 := jw.newRequest(hr) if r1.Session() != sess { t.Fatal("expected request bound to session") } @@ -1664,7 +1664,7 @@ func TestSession_ClaimedNonLastLeaveKeepsGrace(t *testing.T) { // rqA is the live tab: its WebSocket connected, so it is claimed and keeps // the session alive while its (creation-time) deadline ages into the past. - rqA := jw.NewRequest(hr) + rqA := jw.newRequest(hr) if rqA.Session() != sess { t.Fatal("expected rqA bound to session") } @@ -1683,7 +1683,7 @@ func TestSession_ClaimedNonLastLeaveKeepsGrace(t *testing.T) { hr2 := httptest.NewRequest(http.MethodGet, "/2", nil) hr2.RemoteAddr = hr.RemoteAddr hr2.AddCookie(sess.Cookie()) - rqB := jw.NewRequest(hr2) + rqB := jw.newRequest(hr2) if rqB.Session() != sess { t.Fatal("expected rqB bound to session") } @@ -1770,7 +1770,7 @@ func TestSession_CloseDetachesRequestSession(t *testing.T) { } sess.Set("foo", "bar") - rq := jw.NewRequest(hr) + rq := jw.newRequest(hr) if rq.Session() != sess { t.Fatal("expected request session association") } @@ -1851,7 +1851,7 @@ func TestSession_ReplacesOld(t *testing.T) { s1 := jw.NewSession(w1, h1) is.Equal(jw.GetSession(h1), s1) is.Equal(len(w1.Result().Cookies()), 1) - r1 := jw.NewRequest(h1) + r1 := jw.newRequest(h1) is.Equal(r1.Session(), s1) c1 := w1.Result().Cookies()[0] is.Equal(c1.MaxAge, 0) @@ -1871,7 +1871,7 @@ func TestSession_ReplacesOld(t *testing.T) { s2 := jw.NewSession(w2, h2) is.Equal(jw.GetSession(h2), s2) is.Equal(len(w2.Result().Cookies()), 1) - r2 := jw.NewRequest(h2) + r2 := jw.newRequest(h2) is.Equal(r2.Session(), s2) c2 := w2.Result().Cookies()[0] is.Equal(c2.MaxAge, 0) @@ -1892,7 +1892,7 @@ func TestSession_ReplacesOld(t *testing.T) { w4 := httptest.NewRecorder() h4 := httptest.NewRequest("GET", "/", nil) h4.AddCookie(&c1copy) - r4 := jw.NewRequest(h4) + r4 := jw.newRequest(h4) is.Equal(r4.Session(), s1) is.Equal(jw.GetSession(h4), s1) is.Equal(len(w4.Result().Cookies()), 0) diff --git a/testhelpers_test.go b/testhelpers_test.go index a435bc69..5481ff45 100644 --- a/testhelpers_test.go +++ b/testhelpers_test.go @@ -187,7 +187,7 @@ type testHandler struct { } func (h testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - _ = h.Log(h.NewRequest(r).NewElement(h.Template).JawsRender(w, nil)) + _ = h.Log(h.NewRequest(w, r).NewElement(h.Template).JawsRender(w, nil)) } func (jw *Jaws) Handler(outerHTMLTag, name string, dot any) http.Handler { @@ -686,7 +686,7 @@ func newRequestHarness(jw *Jaws, r *http.Request) (rh *requestHarness) { } rr := httptest.NewRecorder() rr.Body = &bytes.Buffer{} - rq := jw.NewRequest(r) + rq := jw.NewRequest(rr, r) if rq == nil || jw.UseRequest(rq.JawsKey, r) != rq { return nil } @@ -788,7 +788,7 @@ func TestTestServe_TimesOutWhenServeNotRunning(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) defer func() { s, ok := recover().(string) if !ok || !strings.Contains(s, "timed out subscribing") { @@ -805,7 +805,7 @@ func TestTestServe_PanicsWhenClosedAfterSubscribing(t *testing.T) { if err != nil { t.Fatal(err) } - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) subCh := make(chan subscription) jw.subCh = subCh readyCh := make(chan struct{}) @@ -833,7 +833,7 @@ func TestTestServe_TimesOutAfterSubscribing(t *testing.T) { t.Fatal(err) } defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + rq := jw.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) subCh := make(chan subscription) jw.subCh = subCh readyCh := make(chan struct{}) diff --git a/testserve_panic_external_test.go b/testserve_panic_external_test.go index 9ff95614..2beb958f 100644 --- a/testserve_panic_external_test.go +++ b/testserve_panic_external_test.go @@ -30,7 +30,7 @@ func startPanickingTestServe(t *testing.T, onPanic func(recovered any)) (jw *jaw go jw.Serve() r := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(r) + rq := jw.NewRequest(httptest.NewRecorder(), r) wantPanic = errors.New("update boom") elem := rq.NewElement(panickingUpdater{value: wantPanic}) const updateTag = tag.Tag("panic-update") diff --git a/testserve_test.go b/testserve_test.go index c3d73800..dc1ee754 100644 --- a/testserve_test.go +++ b/testserve_test.go @@ -10,7 +10,7 @@ func TestTestServe_PanicsWhenJawsAlreadyClosed(t *testing.T) { if err != nil { t.Fatal(err) } - rq := jw.NewRequest(nil) + rq := jw.newRequest(nil) jw.Close() defer func() {