diff --git a/jaws_test.go b/jaws_test.go index cd243bb9..07b35772 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/binary" "errors" + "fmt" "html/template" "io" "math" @@ -3164,7 +3165,7 @@ func TestServeHTTP_TailScript_UnknownSuffixDoesNotDrain(t *testing.T) { w = httptest.NewRecorder() jw.ServeHTTP(w, req) is.Equal(w.Code, http.StatusOK) - is.Equal(strings.Contains(w.Body.String(), `classList?.add("cls");`), true) + is.Equal(strings.Contains(w.Body.String(), `C(1,"cls");`), true) } func TestServeHTTP_TailScript(t *testing.T) { @@ -3187,10 +3188,10 @@ func TestServeHTTP_TailScript(t *testing.T) { jw.ServeHTTP(w, req) is.Equal(w.Code, http.StatusOK) - is.Equal(w.Header().Get("Content-Type"), headerContentTypeJavaScript) + is.Equal(w.Header().Get("Content-Type"), "text/javascript; charset=utf-8") is.Equal(w.Header().Get("Cache-Control"), headerCacheControlNoStore) - is.Equal(strings.Contains(w.Body.String(), `setAttribute("title","\x3c/script>\x3cimg onerror=alert(1) src=x>");`), true) - is.Equal(strings.Contains(w.Body.String(), `classList?.add("cls");`), true) + is.Equal(strings.Contains(w.Body.String(), `A(1,"title\n\x3c/script>\x3cimg onerror=alert(1) src=x>");`), true) + is.Equal(strings.Contains(w.Body.String(), `C(1,"cls");`), true) is.Equal(strings.Contains(w.Body.String(), "kept"), false) is.Equal(jw.RequestCount(), 1) } @@ -3209,6 +3210,7 @@ func TestServeHTTP_TailScript_EndpointIsPerRequest(t *testing.T) { w := httptest.NewRecorder() jw.ServeHTTP(w, req) is.Equal(w.Code, http.StatusOK) + is.Equal(w.Body.Len(), 0) req = httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) req.RemoteAddr = hr.RemoteAddr @@ -3257,7 +3259,7 @@ func TestServeHTTP_TailScript_RejectsRecycledKey(t *testing.T) { w = httptest.NewRecorder() jw.ServeHTTP(w, req) is.Equal(w.Code, http.StatusOK) - is.Equal(strings.Contains(w.Body.String(), `classList?.add("fresh");`), true) + is.Equal(strings.Contains(w.Body.String(), `C(1,"fresh");`), true) is.Equal(strings.Contains(w.Body.String(), "stale"), false) } @@ -3291,7 +3293,7 @@ func TestServeHTTP_TailScript_IPMismatch(t *testing.T) { w = httptest.NewRecorder() jw.ServeHTTP(w, req) is.Equal(w.Code, http.StatusOK) - is.Equal(strings.Contains(w.Body.String(), `classList?.add("cls");`), true) + is.Equal(strings.Contains(w.Body.String(), `C(1,"cls");`), true) } func TestServeHTTP_TailScript_WriteError(t *testing.T) { @@ -4198,6 +4200,75 @@ func BenchmarkAppendJSQuote(b *testing.B) { } } +// BenchmarkRequestDrainTailScript measures representative compact-tail workloads. +func BenchmarkRequestDrainTailScript(b *testing.B) { + b.Run("one-fixup", func(b *testing.B) { + benchmarkRequestDrainTailScript(b, []wire.WsMsg{ + {Jid: 1, What: what.SClass, Data: "cls"}, + }) + }) + + const ( + rows = 10 + columns = 10 + opsPerCell = 3 + firstCellJid = Jid(5) + ) + messages := make([]wire.WsMsg, 0, rows*columns*opsPerCell) + currentJid := firstCellJid + for row := 1; row <= rows; row++ { + for column := 1; column <= columns; column++ { + messages = append( + messages, + wire.WsMsg{Jid: currentJid, What: what.SAttr, Data: "data-state\nhidden"}, + wire.WsMsg{Jid: currentJid, What: what.SAttr, Data: fmt.Sprintf("aria-label\nRow %d, column %d: hidden", row, column)}, + wire.WsMsg{Jid: currentJid, What: what.RAttr, Data: "disabled"}, + ) + currentJid++ + } + } + b.Run("board-300", func(b *testing.B) { + benchmarkRequestDrainTailScript(b, messages) + }) + + messages = messages[:0] + data := "aria-label\n" + strings.Repeat("x", 120) + for id := Jid(1); id <= 100; id++ { + messages = append(messages, wire.WsMsg{Jid: id, What: what.SAttr, Data: data}) + } + b.Run("long-attrs-100", func(b *testing.B) { + benchmarkRequestDrainTailScript(b, messages) + }) +} + +func benchmarkRequestDrainTailScript(b *testing.B, messages []wire.WsMsg) { + b.Helper() + jw, err := New() + if err != nil { + b.Fatal(err) + } + go jw.Serve() + b.Cleanup(func() { jw.Close() }) + rq := jw.newRequest(nil) + rq.muQueue.Lock() + rq.wsQueue = slices.Grow(rq.wsQueue[:0], len(messages)) + rq.muQueue.Unlock() + var tail []byte + var sent bool + b.ReportAllocs() + for b.Loop() { + rq.muQueue.Lock() + rq.tailsent = false + rq.wsQueue = append(rq.wsQueue[:0], messages...) + rq.muQueue.Unlock() + tail, sent = rq.drainTailScript() + } + if !sent { + b.Fatal("drainTailScript did not report the first drain") + } + b.ReportMetric(float64(len(tail)), "tail-B/op") +} + // benchSink consumes drained messages so the outbound loop is not eliminated. var benchSink wire.WsMsg diff --git a/request_test.go b/request_test.go index 2a01f518..9bd27dac 100644 --- a/request_test.go +++ b/request_test.go @@ -6,6 +6,7 @@ import ( "context" "crypto/tls" "encoding/binary" + "encoding/json" "errors" "fmt" "html/template" @@ -15,6 +16,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "os/exec" "reflect" "runtime" "slices" @@ -28,6 +31,7 @@ import ( "github.com/coder/websocket" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/lib/assets" "github.com/linkdata/jaws/lib/jid" "github.com/linkdata/jaws/lib/key" "github.com/linkdata/jaws/lib/tag" @@ -360,6 +364,7 @@ func TestRequest_writeTailScript_EscapesScriptClose(t *testing.T) { t.Fatalf("writeTailScript did not escape in attribute value: %s", s) } th.True(strings.Contains(s, `\x3c/script>`)) + th.True(strings.Contains(s, `A(1,"title\n\x3c/script>\x3cimg onerror=alert(1) src=x>");`)) } func TestRequest_writeTailScript_QuotesAstralAndLineSeparators(t *testing.T) { @@ -375,7 +380,7 @@ func TestRequest_writeTailScript_QuotesAstralAndLineSeparators(t *testing.T) { // text "U0001fffe", so the value must instead survive as literal UTF-8. U+2028 is a // JavaScript line separator that must be escaped so it cannot break the inline // " + messages := []wire.WsMsg{ + {Jid: 1, What: what.SAttr, Data: "title\nsame"}, + {Jid: 1, What: what.SAttr, Data: "title\n" + changedValue}, + {Jid: 1, What: what.SAttr, Data: "no-newline"}, + {Jid: 1, What: what.RAttr, Data: "a\nb"}, + {Jid: 1, What: what.SClass, Data: "bad token"}, + {Jid: 1, What: what.SClass, Data: "ok-last"}, + {Jid: 1, What: what.RClass, Data: "gone"}, + {Jid: 1, What: what.SAttr, Data: "ID\nhacked"}, + {Jid: 1, What: what.RAttr, Data: "iD"}, + {Jid: 2, What: what.SClass, Data: "missing"}, + } + jw, err := New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + defer jw.Close() + rq := jw.newRequest(nil) + defer jw.recycle(rq) + rq.muQueue.Lock() + rq.wsQueue = append(rq.wsQueue, messages...) + rq.muQueue.Unlock() + tail, sent := rq.drainTailScript() + if !sent { + t.Fatal("drainTailScript did not report the first drain") + } + operations := make([][3]string, len(messages)) + for i, msg := range messages { + operations[i] = [3]string{msg.What.String(), msg.Jid.String(), msg.Data} + } + operationsJSON, err := json.Marshal(operations) + if err != nil { + t.Fatal(err) + } + clientJSON, err := json.Marshal(assets.JavascriptText) + if err != nil { + t.Fatal(err) + } + jidPrefixJSON, err := json.Marshal(jid.Prefix) + if err != nil { + t.Fatal(err) + } + + script := `const operations=` + string(operationsJSON) + `; +const clientSource=` + string(clientJSON) + `; +const jidPrefix=` + string(jidPrefixJSON) + `; +const tailEvents=[],tailErrors=[],tailLookups=[]; +const wsEvents=[],wsErrors=[],wsLookups=[]; +function makeElem(events) { + const elem = { + attrs: { title: "same" }, + getAttribute: function(name) { + if (this !== elem) throw new Error("wrong getAttribute receiver"); + events.push(["getAttribute", name]); + return Object.hasOwn(this.attrs, name) ? this.attrs[name] : null; + }, + setAttribute: function(name, value) { + if (this !== elem) throw new Error("wrong setAttribute receiver"); + events.push(["setAttribute", name, value]); + if (name === "") throw new Error("invalid attribute name"); + this.attrs[name] = value; + }, + removeAttribute: function(name) { + if (this !== elem) throw new Error("wrong removeAttribute receiver"); + events.push(["removeAttribute", name]); + }, + classList: { + add: function(name) { + if (this !== elem.classList) throw new Error("wrong classList.add receiver"); + events.push(["classList.add", name]); + if (name === "bad token") throw new Error("invalid class token"); + }, + remove: function(name) { + if (this !== elem.classList) throw new Error("wrong classList.remove receiver"); + events.push(["classList.remove", name]); + }, + }, + }; + return elem; +} +let target=makeElem(tailEvents),lookups=tailLookups; +global.window={ + location:{protocol:"http:",host:"example.test",reload:function(){},assign:function(){}}, + addEventListener:function(){}, + removeEventListener:function(){}, + jawsNames:new Map(), +}; +global.document = { + readyState:"loading", + addEventListener:function(){}, + querySelector:function(){return null}, + querySelectorAll:function(){return []}, + getElementById: function(id) { + if (this !== document) throw new Error("wrong getElementById receiver"); + lookups.push(id); + return id === jidPrefix+"1" ? target : null; + }, +}; +global.XMLHttpRequest=function(){}; +global.Event=function(){}; +global.Node=function(){}; +global.WebSocket=function(){}; +console.error=function(err){tailErrors.push(String(err))}; +const X = "outer-X", I = "outer-I"; +const A = "outer-A", R = "outer-R", C = "outer-C", D = "outer-D"; +process.stderr.write("ignored node stderr\n"); +` + string(tail) + ` +const globals=[X,I,A,R,C,D]; +target=makeElem(wsEvents); +lookups=wsLookups; +eval(clientSource); +for(const operation of operations){ + try{ + jawsPerform(operation[0],operation[1],JSON.stringify(operation[2])); + }catch(err){ + wsErrors.push(String(err)); + } +} +process.stdout.write(JSON.stringify({ + tailEvents,tailErrors,tailLookups, + wsEvents,wsErrors,wsLookups, + globals, +})); +` + var got struct { + TailEvents [][]string `json:"tailEvents"` + TailErrors []string `json:"tailErrors"` + TailLookups []string `json:"tailLookups"` + WSEvents [][]string `json:"wsEvents"` + WSErrors []string `json:"wsErrors"` + WSLookups []string `json:"wsLookups"` + Globals []string `json:"globals"` + } + if err := json.Unmarshal([]byte(runNodeSnippet(t, script)), &got); err != nil { + t.Fatal(err) + } + wantEvents := [][]string{ + {"getAttribute", "title"}, + {"getAttribute", "title"}, + {"setAttribute", "title", changedValue}, + {"getAttribute", ""}, + {"setAttribute", "", "no-newline"}, + {"removeAttribute", "a\nb"}, + {"classList.add", "bad token"}, + {"classList.add", "ok-last"}, + {"classList.remove", "gone"}, + } + if !reflect.DeepEqual(got.TailEvents, wantEvents) { + t.Errorf("tail DOM events = %#v, want %#v", got.TailEvents, wantEvents) + } + if !reflect.DeepEqual(got.WSEvents, wantEvents) { + t.Errorf("WebSocket DOM events = %#v, want %#v", got.WSEvents, wantEvents) + } + wantErrors := []string{ + "Error: invalid attribute name", + "Error: invalid class token", + "jaws: refusing to change reserved attribute 'id'", + "jaws: refusing to remove reserved attribute 'id'", + } + if !reflect.DeepEqual(got.TailErrors, wantErrors) { + t.Errorf("tail errors = %#v, want %#v", got.TailErrors, wantErrors) + } + // TailHTML ignores missing elements; the WebSocket client reports them. + wantWSErrors := append(append([]string(nil), wantErrors...), "jaws: element not found: "+jid.Prefix+"2") + if !reflect.DeepEqual(got.WSErrors, wantWSErrors) { + t.Errorf("WebSocket errors = %#v, want %#v", got.WSErrors, wantWSErrors) + } + wantLookups := []string{ + jid.Prefix + "1", jid.Prefix + "1", jid.Prefix + "1", jid.Prefix + "1", jid.Prefix + "1", + jid.Prefix + "1", jid.Prefix + "1", jid.Prefix + "1", jid.Prefix + "1", jid.Prefix + "2", + } + if !reflect.DeepEqual(got.TailLookups, wantLookups) { + t.Errorf("tail element lookups = %#v, want %#v", got.TailLookups, wantLookups) + } + if !reflect.DeepEqual(got.WSLookups, wantLookups) { + t.Errorf("WebSocket element lookups = %#v, want %#v", got.WSLookups, wantLookups) + } + wantGlobals := []string{"outer-X", "outer-I", "outer-A", "outer-R", "outer-C", "outer-D"} + if !reflect.DeepEqual(got.Globals, wantGlobals) { + t.Errorf("outer globals = %#v, want %#v", got.Globals, wantGlobals) + } } // TestRequest_TailScriptConcurrentWithRecycle exercises a /jaws/.tail fetch diff --git a/serve.go b/serve.go index 9ee9f304..603cd53a 100644 --- a/serve.go +++ b/serve.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/linkdata/jaws/lib/jid" "github.com/linkdata/jaws/lib/key" "github.com/linkdata/jaws/lib/what" "github.com/linkdata/jaws/lib/wire" @@ -392,7 +393,24 @@ func (jw *Jaws) Setup(handleFn HandleFunc, prefix string, extras ...any) (err er // The tail-script subsystem serves one-shot attribute and class updates queued // during initial rendering before the WebSocket connects. -const headerContentTypeJavaScript = "text/javascript" +const headerContentTypeJavaScript = "text/javascript; charset=utf-8" + +// Tail script fragments define the isolated fixup wrapper and operation helpers. +const ( + tailScriptStart = `{const X=f=>(i,d)=>{try{let e=document.getElementById("` + jid.Prefix + `"+i);e&&f(e,d)}catch(e){console.error(e)}}` + tailScriptGuard = `,I=(n,a)=>{if(n.toLowerCase()==="id")throw"jaws: refusing to "+a+" reserved attribute 'id'"}` + tailScriptA = `,A=X((e,d)=>{let i=d.indexOf("\n"),n=d.substring(0,i),v=d.substring(i+1);I(n,"change");e.getAttribute(n)===v||e.setAttribute(n,v)})` + tailScriptR = `,R=X((e,n)=>{I(n,"remove");e.removeAttribute(n)})` + tailScriptC = `,C=X((e,c)=>e.classList.add(c))` + tailScriptD = `,D=X((e,c)=>e.classList.remove(c))` +) + +const ( + tailScriptMaskA byte = 1 << iota + tailScriptMaskR + tailScriptMaskC + tailScriptMaskD +) // appendJSQuote appends s as a JavaScript string literal safe to embed in an inline //