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
//