From e8af702a1c7d710289a890651e7d084657e8c609 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 13:24:41 +0200 Subject: [PATCH 01/41] starting to split core --- core/assets/dateformat.go | 4 + core/assets/defaultcookiename.go | 33 +++ core/assets/jaws.css | 16 ++ core/assets/jaws.js | 479 +++++++++++++++++++++++++++++++ core/assets/js.go | 113 ++++++++ core/binding.go | 4 +- core/dateformat.go | 4 +- core/defaultcookiename.go | 27 +- core/element_test.go | 3 +- core/errillegaltagtype.go | 16 +- core/errnotcomparable.go | 28 +- core/errnotcomparable_test.go | 8 +- core/errnotusableastag.go | 124 +------- core/errnotusableastag_test.go | 4 +- core/eventhandler_test.go | 3 +- core/getter.go | 4 +- core/htmlgetterfunc.go | 8 +- core/jaws_test.go | 3 +- core/js.go | 98 +------ core/makehtmlgetter.go | 8 +- core/message.go | 21 +- core/setter.go | 6 +- core/setterfloat64.go | 8 +- core/stringgetterfunc.go | 4 +- core/tag.go | 90 +----- core/tag_test.go | 7 +- core/taggetter.go | 9 +- core/tags/context.go | 7 + core/tags/errillegaltagtype.go | 18 ++ core/tags/errnotcomparable.go | 31 ++ core/tags/errnotusableastag.go | 136 +++++++++ core/tags/tag.go | 100 +++++++ core/tags/taggetter.go | 5 + core/wire/message.go | 23 ++ core/wire/wsmsg.go | 79 +++++ core/wsmsg.go | 74 +---- ui/html_widgets_test.go | 3 +- ui/jsvar.go | 3 +- ui/requestwriter_test.go | 3 +- 39 files changed, 1137 insertions(+), 477 deletions(-) create mode 100644 core/assets/dateformat.go create mode 100644 core/assets/defaultcookiename.go create mode 100644 core/assets/jaws.css create mode 100644 core/assets/jaws.js create mode 100644 core/assets/js.go create mode 100644 core/tags/context.go create mode 100644 core/tags/errillegaltagtype.go create mode 100644 core/tags/errnotcomparable.go create mode 100644 core/tags/errnotusableastag.go create mode 100644 core/tags/tag.go create mode 100644 core/tags/taggetter.go create mode 100644 core/wire/message.go create mode 100644 core/wire/wsmsg.go diff --git a/core/assets/dateformat.go b/core/assets/dateformat.go new file mode 100644 index 00000000..13ffefda --- /dev/null +++ b/core/assets/dateformat.go @@ -0,0 +1,4 @@ +package assets + +// ISO8601 is the date format used by date input widgets (YYYY-MM-DD). +const ISO8601 = "2006-01-02" diff --git a/core/assets/defaultcookiename.go b/core/assets/defaultcookiename.go new file mode 100644 index 00000000..85a9770c --- /dev/null +++ b/core/assets/defaultcookiename.go @@ -0,0 +1,33 @@ +package assets + +import ( + "os" + "path/filepath" + "strings" +) + +// DefaultCookieName holds the default JaWS cookie name. +// It will be generated from the executable name, or "jaws" if that fails. +var DefaultCookieName string + +func init() { + exename, _ := os.Executable() + DefaultCookieName = MakeCookieName(exename) +} + +// MakeCookieName creates a cookie-safe name from an executable path. +func MakeCookieName(exename string) (cookie string) { + cookie = "jaws" + exename = filepath.Base(exename) + exename = strings.TrimSuffix(exename, filepath.Ext(exename)) + var b []byte + for _, ch := range exename { + if ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ('0' <= ch && ch <= '9') { + b = append(b, byte(ch)) //#nosec G115 + } + } + if len(b) > 0 { + cookie = string(b) + } + return +} diff --git a/core/assets/jaws.css b/core/assets/jaws.css new file mode 100644 index 00000000..40e471f6 --- /dev/null +++ b/core/assets/jaws.css @@ -0,0 +1,16 @@ +.jaws-lost { + display: flex; + position: relative; + z-index: 1000; + height: 3em; + width: 100vw; + left: 50%; + margin-left: -50vw; + right: 50%; + margin-right:-50vw; + justify-content: center; + align-items: center; + background-color: red; + color: white; +} + diff --git a/core/assets/jaws.js b/core/assets/jaws.js new file mode 100644 index 00000000..f23256bd --- /dev/null +++ b/core/assets/jaws.js @@ -0,0 +1,479 @@ +// https://github.com/linkdata/jaws +// +// This script trusts the server to HTML escape user +// provided data before sending it. The script must not +// itself HTML-escape strings from the server, as the +// server needs to be able to inject arbitrary HTML. +// +// The script needs 'jawsKey' to be defined in a HTML +// meta tag. This is a per-request randomly generated +// key used to associate the WebSocket callback with +// the initial HTTP request. + +var jaws = null; +var jawsIdPrefix = 'Jid.'; + +function jawsContains(a, v) { + return a.indexOf(String(v).trim().toLowerCase()) !== -1; +} + +function jawsIsCheckable(v) { + return jawsContains(['checkbox', 'radio'], v); +} + +function jawsHasSelection(v) { + return jawsContains(['text', 'search', 'url', 'tel', 'password'], v); +} + +function jawsIsInputTag(v) { + return jawsContains(['input', 'select', 'textarea'], v); +} + +function jawsIsTrue(v) { + return jawsContains(['true', 't', 'on', '1', 'yes', 'y', 'selected'], v); +} + +function jawsGetName(e) { + var elem = e; + while (elem != null) { + var name = elem.getAttribute('name'); + if (name == null && elem.tagName.toLowerCase() === 'button') { + name = elem.textContent; + } + if (name != null) { + return name.replaceAll('\t', ' '); + } + elem = elem.parentElement; + } + return e.id; +} + +function jawsClickHandler(e) { + if (jaws instanceof WebSocket && e instanceof Event) { + e.stopPropagation(); + var elem = e.target; + var val = jawsGetName(elem); + while (elem != null) { + if (elem.id.startsWith(jawsIdPrefix) && !jawsIsInputTag(elem.tagName)) { + val += "\t" + elem.id; + } + elem = elem.parentElement; + } + jaws.send("Click\t\t" + JSON.stringify(val) + "\n"); + } +} + +function jawsInputHandler(e) { + if (jaws instanceof WebSocket && e instanceof Event) { + e.stopPropagation(); + var val; + var elem = e.currentTarget; + if (jawsIsCheckable(elem.getAttribute('type'))) { + val = elem.checked; + } else if (elem.tagName.toLowerCase() === 'option') { + val = elem.selected; + } else { + val = elem.value; + } + jaws.send("Input\t" + elem.id + "\t" + JSON.stringify(val) + "\n"); + } +} + +function jawsRemoving(topElem) { + var elements = topElem.querySelectorAll('[id^="' + jawsIdPrefix + '"]'); + if (elements.length == 0) return; + var val = ''; + for (var i = 0; i < elements.length; i++) { + if (i > 0) { + val += '\t'; + } + val += elements[i].id; + } + jaws.send("Remove\t" + topElem.id + "\t" + JSON.stringify(val) + "\n"); +} + +function jawsAttach(elem) { + if (elem.hasAttribute("data-jawsname")) { + var name = elem.dataset.jawsname; + window.jawsNames[name] = elem.id; + if (elem.hasAttribute("data-jawsdata")) { + jawsVar(name, JSON.parse(elem.dataset.jawsdata), 'Set'); + } + return; + } + if (jawsIsInputTag(elem.tagName)) { + elem.addEventListener('input', jawsInputHandler, false); + return; + } + elem.addEventListener('click', jawsClickHandler, false); +} + +function jawsAttachChildren(topElem) { + topElem.querySelectorAll('[data-jawsonchangesubmit]').forEach(function(elem) { + elem.addEventListener('change', function() { this.form.submit(); }); + }); + topElem.querySelectorAll('[id^="' + jawsIdPrefix + '"]').forEach(jawsAttach); + return topElem; +} + +function jawsAlert(data) { + var lines = data.split('\n'); + var type = lines.shift(); + var message = lines.join('\n'); + if (typeof bootstrap !== 'undefined') { + var alertsElem = document.getElementById('jaws-alerts'); + if (alertsElem) { + var wrapper = document.createElement('div'); + wrapper.innerHTML = ''; + alertsElem.append(wrapper); + return; + } + } + console.log("jaws: " + type + ": " + message); +} + +function jawsList(idlist) { + var i; + var elements = []; + var idstrings = idlist.split(' '); + for (i = 0; i < idstrings.length; i++) { + var elem = document.getElementById(idstrings[i]); + if (elem) { + elem.dataset.jidsort = i; + elements.push(elem); + } + } + elements.sort(function (a, b) { + return +a.dataset.jidsort - +b.dataset.jidsort; + }); + for (i = 0; i < elements.length; i++) { + delete elements[i].dataset.jidsort; + } + return elements; +} + +function jawsOrder(idlist) { + var i; + var elements = jawsList(idlist); + for (i = 0; i < elements.length; i++) { + elements[i].parentElement.appendChild(elements[i]); + } +} + +function jawsSetValue(elem, str) { + var elemtype = elem.getAttribute('type'); + if (jawsIsCheckable(elemtype)) { + elem.checked = jawsIsTrue(str); + return; + } + if (elem.tagName.toLowerCase() === 'option') { + elem.selected = jawsIsTrue(str); + return; + } + if (elem.tagName.toLowerCase() === 'textarea') { + if (elem.textContent != str) { + elem.textContent = str; + // work around browser bug where textContent + // and textLength go out of sync sometimes + if (elem.textLength != elem.textContent.length) { + var parent = elem.parentNode; + var clone = document.createElement(elem.tagName); + for (var i = 0; i < elem.attributes.length; i++) { + clone.setAttribute(elem.attributes[i].nodeName, elem.attributes[i].nodeValue); + } + clone.textContent = str; + parent.insertBefore(clone, elem); + elem.remove(); + jawsAttach(clone); + } + } + return; + } + if (elem.value == str) { + return; + } + if (jawsHasSelection(elemtype)) { + var ss = elem.selectionStart; + var se = elem.selectionEnd; + var oldVal = elem.value; + var delta = str.indexOf(oldVal); + elem.value = str; + if (delta == -1) { + delta = oldVal.indexOf(str); + if (delta == -1) return; + delta = -delta; + } + elem.selectionStart = ss + delta; + elem.selectionEnd = se + delta; + return; + } + elem.value = str; +} + +function jawsLost() { + var delay = 1; + var innerHTML = 'Server connection lost'; + if (jaws instanceof Date) { + var elapsed = Math.floor((new Date() - jaws) / 1000); + if (elapsed > 0) { + var units = ' second'; + delay = elapsed; + if (elapsed >= 60) { + delay = 60; + units = ' minute'; + elapsed = Math.floor(elapsed / 60); + if (elapsed >= 60) { + units = ' hour'; + elapsed = Math.floor(elapsed / 60); + } + } + if (elapsed > 1) units += 's'; + innerHTML += ' ' + elapsed + units + ' ago'; + } + } + innerHTML += '. Trying to reconnect.'; + var elem = document.getElementById('jaws-lost'); + if (elem == null) { + elem = jawsElement('
' + innerHTML + '
'); + document.body.prepend(elem); + document.body.scrollTop = document.documentElement.scrollTop = 0; + } else { + elem.innerHTML = innerHTML; + } + setTimeout(jawsReconnect, delay * 1000); +} + +function jawsHandleReconnect(e) { + if (e.currentTarget.readyState == 4) { + if (e.currentTarget.status == 204) { + window.location.reload(); + } else { + jawsLost(); + } + } +} + +function jawsReconnect() { + var req = new XMLHttpRequest(); + req.open("GET", window.location.protocol + "//" + window.location.host + "/jaws/.ping", true); + req.addEventListener('readystatechange', jawsHandleReconnect); + req.send(null); +} + +function jawsFailed(e) { + if (jaws instanceof WebSocket) { + jaws = new Date(); + jawsReconnect(); + } +} + +function jawsUnloading() { + if (jaws instanceof WebSocket) { + jaws.removeEventListener('close', jawsFailed); + jaws.removeEventListener('error', jawsFailed); + jaws.close(); + jaws = null; + } +} + +function jawsElement(html) { + var template = document.createElement('template'); + template.innerHTML = html; + return template.content; +} + +function jawsWhere(elem, pos) { + var where = null; + if (pos && pos !== 'null') { + where = document.getElementById(pos); + if (where == null) { + where = elem.children[parseInt(pos)]; + } + } + if (!(where instanceof Node)) { + console.log("jaws: id " + elem.id + " has no position " + pos); + } + return where; +} + +function jawsInsert(elem, data) { + var idx = data.indexOf('\n'); + var where = jawsWhere(elem, data.substring(0, idx)); + if (where instanceof Node) { + elem.insertBefore(jawsAttachChildren(jawsElement(data.substring(idx + 1))), where); + } +} + +function jawsSetAttr(elem, data) { + var idx = data.indexOf('\n'); + elem.setAttribute(data.substring(0, idx), data.substring(idx + 1)); +} + +function jawsMessage(e) { + var orders = e.data.split('\n'); + var i; + for (i = 0; i < orders.length; i++) { + if (orders[i]) { + var parts = orders[i].split('\t'); + jawsPerform(parts.shift(), parts.shift(), parts.shift()); + } + } +} + +function jawsVar(name, data, operation) { + var keys = name.split('.').filter(function(key){return key != "";}); + if (keys.length > 0) { + var obj = window; + var lastkey = keys[keys.length - 1]; + var i; + var path = keys.slice(1).join("."); + name = keys[0]; + for (i = 0; i < keys.length - 1; i++) { + if (!obj.hasOwnProperty(keys[i])) { + throw "jaws: object undefined: " + name; + } + obj = obj[keys[i]]; + } + switch (operation) { + case undefined: + if (data === undefined) { + data = obj[lastkey]; + } else { + obj[lastkey] = data; + } + if (jaws instanceof WebSocket && jaws.readyState === 1) { + var id = window.jawsNames[name]; + if (typeof id === 'string' && id.startsWith(jawsIdPrefix)) { + jaws.send("Set\t" + id + "\t" + path + "=" + JSON.stringify(data) + "\n"); + } + } + return data; + case 'Call': + if (typeof obj[lastkey] === 'function') { + obj[lastkey](data); + return; + } + throw "jaws: not a function: " + name + path; + case 'Set': + if (typeof obj[lastkey] !== 'function') { + obj[lastkey] = data; + } + return data; + default: + throw "jaws: unknown operation: " + operation; + } + } +} + +function jawsPerform(what, id, data) { + var path = ""; + if (what == 'Set' || what == 'Call') { + var equalPos = data.indexOf("="); + if (equalPos > 0) { + path = data.slice(0, equalPos); + } + data = data.slice(equalPos + 1); + } + data = JSON.parse(data); + switch (what) { + case 'Reload': + window.location.reload(); + return; + case 'Redirect': + window.location.assign(data); + return; + case 'Alert': + jawsAlert(data); + return; + case 'Order': + jawsOrder(data); + return; + } + var elem = document.getElementById(id); + if (elem === null) { + throw "jaws: element not found: " + id; + } + var where = null; + switch (what) { + case 'Inner': + jawsRemoving(elem); + elem.innerHTML = data; + jawsAttachChildren(elem); + return; + case 'Value': + jawsSetValue(elem, data); + return; + case 'Append': + elem.appendChild(jawsAttachChildren(jawsElement(data))); + return; + case 'Replace': + jawsRemoving(elem); + elem.replaceWith(jawsAttachChildren(jawsElement(data))); + return; + case 'Delete': + jawsRemoving(elem); + elem.remove(); + return; + case 'Remove': + where = jawsWhere(elem, data); + if (where instanceof Node) { + jawsRemoving(where); + elem.removeChild(where); + } + return; + case 'Insert': + jawsInsert(elem, data); + return; + case 'SAttr': + jawsSetAttr(elem, data); + return; + case 'RAttr': + elem.removeAttribute(data); + return; + case 'SClass': + elem.classList.add(data); + return; + case 'RClass': + elem.classList.remove(data); + return; + case 'Call': + jawsVar(path, data, what); + return; + case 'Set': + if (elem.dataset.jawsname) { + jawsVar(elem.dataset.jawsname + "." + path, data, what); + } else { + console.log("jaws: id " + id + " is not a JsVar"); + } + return; + } + throw "jaws: unknown operation: " + what; +} + +function jawsPageshow(e) { + if (e.persisted) { + window.location.reload(); + } +} + +function jawsConnect() { + var wsScheme = 'ws://'; + if (window.location.protocol === 'https:') { + wsScheme = 'wss://'; + } + window.addEventListener('beforeunload', jawsUnloading); + window.addEventListener('pageshow', jawsPageshow); + jaws = new WebSocket(wsScheme + window.location.host + '/jaws/' + encodeURIComponent(document.querySelector('meta[name="jawsKey"]').content)); + jaws.addEventListener('message', jawsMessage); + jaws.addEventListener('close', jawsFailed); + jaws.addEventListener('error', jawsFailed); +} + +window.jawsNames = {}; +jawsAttachChildren(document); +if (document.readyState === 'complete' || document.readyState === 'interactive') { + jawsConnect(); +} else { + window.addEventListener('DOMContentLoaded', jawsConnect); +} diff --git a/core/assets/js.go b/core/assets/js.go new file mode 100644 index 00000000..40450961 --- /dev/null +++ b/core/assets/js.go @@ -0,0 +1,113 @@ +package assets + +import ( + _ "embed" + "mime" + "net/url" + "path/filepath" + "strconv" + "strings" +) + +// JavascriptText is the source code for the client-side JaWS Javascript library. +// +//go:embed jaws.js +var JavascriptText []byte + +//go:embed jaws.css +var JawsCSS []byte + +// JawsKeyAppend appends the JaWS key as a string to the buffer. +func JawsKeyAppend(b []byte, jawsKey uint64) []byte { + if jawsKey != 0 { + b = strconv.AppendUint(b, jawsKey, 32) + } + return b +} + +// JawsKeyString returns the string to be used for the given JaWS key. +func JawsKeyString(jawsKey uint64) string { + return string(JawsKeyAppend(nil, jawsKey)) +} + +// JawsKeyValue parses a key string (as returned JawsKeyString) into a uint64. +func JawsKeyValue(jawsKey string) uint64 { + slashIdx := strings.IndexByte(jawsKey, '/') + if slashIdx < 0 { + slashIdx = len(jawsKey) + } + if val, err := strconv.ParseUint(jawsKey[:slashIdx], 32, 64); err == nil { + return val + } + return 0 +} + +// PreloadHTML returns HTML code to load the given resources efficiently. +func PreloadHTML(urls ...*url.URL) (htmlcode, faviconurl string) { + var jsurls, cssurls []string + var favicontype string + var buf []byte + for _, u := range urls { + if u != nil { + var asattr string + ext := strings.ToLower(filepath.Ext(u.Path)) + mimetype := mime.TypeByExtension(ext) + if semi := strings.IndexByte(mimetype, ';'); semi > 0 { + mimetype = mimetype[:semi] + } + urlstr := u.String() + switch ext { + case ".js": + jsurls = append(jsurls, urlstr) + continue + case ".css": + cssurls = append(cssurls, urlstr) + continue + default: + if strings.HasPrefix(mimetype, "image") { + asattr = "image" + if strings.HasPrefix(filepath.Base(u.Path), "favicon") { + favicontype = mimetype + faviconurl = urlstr + continue + } + } else if strings.HasPrefix(mimetype, "font") { + asattr = "font" + } + } + buf = append(buf, `\n"...) + } + } + for _, urlstr := range cssurls { + buf = append(buf, `\n"...) + } + if faviconurl != "" { + buf = append(buf, `\n"...) + } + for _, urlstr := range jsurls { + buf = append(buf, `\n"...) + } + htmlcode = string(buf) + return +} diff --git a/core/binding.go b/core/binding.go index 601d3139..612cbe4b 100644 --- a/core/binding.go +++ b/core/binding.go @@ -3,6 +3,8 @@ package jaws import ( "fmt" "html/template" + + "github.com/linkdata/jaws/core/tags" ) type binding[T comparable] struct { @@ -40,7 +42,7 @@ func (bind binding[T]) JawsSet(elem *Element, value T) (err error) { return } -func (bind binding[T]) JawsGetTag(*Request) any { +func (bind binding[T]) JawsGetTag(tags.Context) any { return bind.ptr } diff --git a/core/dateformat.go b/core/dateformat.go index 1755573c..89566d49 100644 --- a/core/dateformat.go +++ b/core/dateformat.go @@ -1,4 +1,6 @@ package jaws +import "github.com/linkdata/jaws/core/assets" + // ISO8601 is the date format used by date input widgets (YYYY-MM-DD). -const ISO8601 = "2006-01-02" +const ISO8601 = assets.ISO8601 diff --git a/core/defaultcookiename.go b/core/defaultcookiename.go index 1137261f..d94c879b 100644 --- a/core/defaultcookiename.go +++ b/core/defaultcookiename.go @@ -1,32 +1,11 @@ package jaws -import ( - "os" - "path/filepath" - "strings" -) +import "github.com/linkdata/jaws/core/assets" // DefaultCookieName holds the default JaWS cookie name. // It will be generated from the executable name, or "jaws" if that fails. -var DefaultCookieName string - -func init() { - exename, _ := os.Executable() - DefaultCookieName = makeCookieName(exename) -} +var DefaultCookieName = assets.DefaultCookieName func makeCookieName(exename string) (cookie string) { - cookie = "jaws" - exename = filepath.Base(exename) - exename = strings.TrimSuffix(exename, filepath.Ext(exename)) - var b []byte - for _, ch := range exename { - if ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ('0' <= ch && ch <= '9') { - b = append(b, byte(ch)) //#nosec G115 - } - } - if len(b) > 0 { - cookie = string(b) - } - return + return assets.MakeCookieName(exename) } diff --git a/core/element_test.go b/core/element_test.go index eba417d8..0e59bafb 100644 --- a/core/element_test.go +++ b/core/element_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/what" ) @@ -69,7 +70,7 @@ type testApplyGetterAll struct { initErr error } -func (a testApplyGetterAll) JawsGetTag(*Request) any { return Tag("tg") } +func (a testApplyGetterAll) JawsGetTag(tags.Context) any { return Tag("tg") } func (a testApplyGetterAll) JawsClick(*Element, string) error { return ErrEventUnhandled } diff --git a/core/errillegaltagtype.go b/core/errillegaltagtype.go index 476d6130..e2a84f40 100644 --- a/core/errillegaltagtype.go +++ b/core/errillegaltagtype.go @@ -1,18 +1,6 @@ package jaws -import "fmt" +import "github.com/linkdata/jaws/core/tags" // ErrIllegalTagType is returned when a UI tag type is disallowed -var ErrIllegalTagType errIllegalTagType - -type errIllegalTagType struct { - tag any -} - -func (e errIllegalTagType) Error() string { - return fmt.Sprintf("illegal tag type %T", e.tag) -} - -func (errIllegalTagType) Is(other error) bool { - return other == ErrIllegalTagType -} +var ErrIllegalTagType = tags.ErrIllegalTagType diff --git a/core/errnotcomparable.go b/core/errnotcomparable.go index fcff9ea1..4d1977e5 100644 --- a/core/errnotcomparable.go +++ b/core/errnotcomparable.go @@ -1,32 +1,10 @@ package jaws -import ( - "reflect" -) +import "github.com/linkdata/jaws/core/tags" // ErrNotComparable is returned when a UI object or tag is not comparable. -var ErrNotComparable errNotComparable - -type errNotComparable struct { - t reflect.Type -} - -func (e errNotComparable) Error() (s string) { - if e.t != nil { - s = e.t.String() + " is " - } - return s + "not comparable" -} - -func (errNotComparable) Is(target error) bool { - return target == ErrNotComparable -} +var ErrNotComparable = tags.ErrNotComparable func newErrNotComparable(x any) error { - if x != nil { - if v := reflect.ValueOf(x); !v.Comparable() { - return errNotComparable{t: reflect.TypeOf(x)} - } - } - return nil + return tags.NewErrNotComparable(x) } diff --git a/core/errnotcomparable_test.go b/core/errnotcomparable_test.go index e5acc828..988fd67b 100644 --- a/core/errnotcomparable_test.go +++ b/core/errnotcomparable_test.go @@ -20,12 +20,8 @@ func Test_newErrNotComparable_Error(t *testing.T) { t.Fatalf("unexpected error text %q, want %q", got, want) } - var typed errNotComparable - if !errors.As(err, &typed) { - t.Fatalf("expected errNotComparable, got %T", err) - } - if got := typed.Error(); got != want { - t.Fatalf("unexpected typed error text %q, want %q", got, want) + if !errors.Is(err, ErrNotComparable) { + t.Fatalf("expected ErrNotComparable, got %v", err) } } diff --git a/core/errnotusableastag.go b/core/errnotusableastag.go index 9805d967..674e6bd6 100644 --- a/core/errnotusableastag.go +++ b/core/errnotusableastag.go @@ -1,134 +1,20 @@ package jaws import ( - "fmt" "reflect" - "strconv" + + "github.com/linkdata/jaws/core/tags" ) // ErrNotUsableAsTag is returned when a value cannot be used as a tag. // // It is also matchable as ErrNotComparable for backwards compatibility. -var ErrNotUsableAsTag errNotUsableAsTag - -type errNotUsableAsTag struct { - t reflect.Type - tagGetterPath string - tagGetterType reflect.Type -} - -func (e errNotUsableAsTag) Error() (s string) { - if e.t != nil { - s = e.t.String() + " is " - } - s += "not usable as tag" - if e.tagGetterType != nil { - return s + fmt.Sprintf("; found nested TagGetter at %s (%s); hint: implement JawsGetTag(*Request) on this type to delegate to that value, or pass that nested TagGetter directly", e.tagGetterPath, e.tagGetterType) - } - return s + "; found no nested TagGetter; hint: use a comparable tag value, or implement JawsGetTag(*Request) and return a comparable tag" -} - -func (errNotUsableAsTag) Is(target error) bool { - return target == ErrNotUsableAsTag || target == ErrNotComparable -} +var ErrNotUsableAsTag = tags.ErrNotUsableAsTag func newErrNotUsableAsTag(x any) error { - if err := newErrNotComparable(x); err != nil { - retErr := errNotUsableAsTag{t: reflect.TypeOf(x)} - if path, tgType, ok := findTagGetter(x); ok { - retErr.tagGetterPath = path - retErr.tagGetterType = tgType - } - return retErr - } - return nil + return tags.NewErrNotUsableAsTag(x) } -var tagGetterType = reflect.TypeFor[TagGetter]() - func findTagGetter(x any) (path string, tgType reflect.Type, found bool) { - if x == nil { - return - } - type seenPtr struct { - t reflect.Type - ptr uintptr - } - seen := map[seenPtr]struct{}{} - var walk func(v reflect.Value, currentPath string, depth int) bool - walk = func(v reflect.Value, currentPath string, depth int) bool { - if !v.IsValid() || depth > 10 { - return false - } - t := v.Type() - if t.Implements(tagGetterType) { - path = currentPath - if path == "" { - path = "" - } - tgType = t - found = true - return true - } - switch v.Kind() { - case reflect.Interface: - if v.IsNil() { - return false - } - return walk(v.Elem(), currentPath, depth+1) - case reflect.Pointer: - if v.IsNil() { - return false - } - p := seenPtr{t: t, ptr: v.Pointer()} - if _, ok := seen[p]; ok { - return false - } - seen[p] = struct{}{} - return walk(v.Elem(), currentPath, depth+1) - case reflect.Struct: - for i := range t.NumField() { - next := t.Field(i).Name - if currentPath != "" { - next = currentPath + "." + next - } - if walk(v.Field(i), next, depth+1) { - return true - } - } - case reflect.Array: - n := min(v.Len(), 4) - for i := range n { - next := "[" + strconv.Itoa(i) + "]" - if currentPath != "" { - next = currentPath + next - } - if walk(v.Index(i), next, depth+1) { - return true - } - } - case reflect.Slice: - if v.IsNil() { - return false - } - p := seenPtr{t: t, ptr: v.Pointer()} - if _, ok := seen[p]; ok { - return false - } - seen[p] = struct{}{} - n := min(v.Len(), 4) - for i := range n { - next := "[" + strconv.Itoa(i) + "]" - if currentPath != "" { - next = currentPath + next - } - if walk(v.Index(i), next, depth+1) { - return true - } - } - } - return false - } - walk(reflect.ValueOf(x), path, 0) - return + return tags.FindTagGetter(x) } diff --git a/core/errnotusableastag_test.go b/core/errnotusableastag_test.go index b50fc0b8..ddd97b9e 100644 --- a/core/errnotusableastag_test.go +++ b/core/errnotusableastag_test.go @@ -3,11 +3,13 @@ package jaws import ( "reflect" "testing" + + "github.com/linkdata/jaws/core/tags" ) type testFindTagGetter struct{} -func (testFindTagGetter) JawsGetTag(*Request) any { +func (testFindTagGetter) JawsGetTag(tags.Context) any { return Tag("tg") } diff --git a/core/eventhandler_test.go b/core/eventhandler_test.go index ec24b0a5..d22daa18 100644 --- a/core/eventhandler_test.go +++ b/core/eventhandler_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/what" ) @@ -34,7 +35,7 @@ func (t *testJawsEvent) JawsEvent(e *Element, wht what.What, val string) (err er return } -func (t *testJawsEvent) JawsGetTag(*Request) (tag any) { +func (t *testJawsEvent) JawsGetTag(tags.Context) (tag any) { return t.tag } diff --git a/core/getter.go b/core/getter.go index 8d37cb4f..c8fc3b7e 100644 --- a/core/getter.go +++ b/core/getter.go @@ -3,6 +3,8 @@ package jaws import ( "errors" "fmt" + + "github.com/linkdata/jaws/core/tags" ) var ErrValueNotSettable = errors.New("value not settable") @@ -23,7 +25,7 @@ func (s getterStatic[T]) JawsGet(*Element) T { return s.v } -func (s getterStatic[T]) JawsGetTag(*Request) any { +func (s getterStatic[T]) JawsGetTag(tags.Context) any { return nil } diff --git a/core/htmlgetterfunc.go b/core/htmlgetterfunc.go index 629e355e..9a38f68f 100644 --- a/core/htmlgetterfunc.go +++ b/core/htmlgetterfunc.go @@ -1,6 +1,10 @@ package jaws -import "html/template" +import ( + "html/template" + + "github.com/linkdata/jaws/core/tags" +) type htmlGetterFunc struct { fn func(*Element) template.HTML @@ -13,7 +17,7 @@ func (g *htmlGetterFunc) JawsGetHTML(e *Element) template.HTML { return g.fn(e) } -func (g *htmlGetterFunc) JawsGetTag(e *Request) any { +func (g *htmlGetterFunc) JawsGetTag(tags.Context) any { return g.tags } diff --git a/core/jaws_test.go b/core/jaws_test.go index 117bc490..f655854b 100644 --- a/core/jaws_test.go +++ b/core/jaws_test.go @@ -13,13 +13,14 @@ import ( "testing" "time" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/secureheaders" "github.com/linkdata/jaws/what" ) type testBroadcastTagGetter struct{} -func (testBroadcastTagGetter) JawsGetTag(*Request) any { +func (testBroadcastTagGetter) JawsGetTag(tags.Context) any { return Tag("expanded") } diff --git a/core/js.go b/core/js.go index e4e5374a..f319c97b 100644 --- a/core/js.go +++ b/core/js.go @@ -1,113 +1,33 @@ package jaws import ( - _ "embed" - "mime" "net/url" - "path/filepath" - "strconv" - "strings" + + "github.com/linkdata/jaws/core/assets" ) // JavascriptText is the source code for the client-side JaWS Javascript library. -// -//go:embed jaws.js -var JavascriptText []byte +var JavascriptText = assets.JavascriptText -//go:embed jaws.css -var JawsCSS []byte +// JawsCSS is the built-in JaWS stylesheet. +var JawsCSS = assets.JawsCSS // JawsKeyAppend appends the JaWS key as a string to the buffer. func JawsKeyAppend(b []byte, jawsKey uint64) []byte { - if jawsKey != 0 { - b = strconv.AppendUint(b, jawsKey, 32) - } - return b + return assets.JawsKeyAppend(b, jawsKey) } // JawsKeyString returns the string to be used for the given JaWS key. func JawsKeyString(jawsKey uint64) string { - return string(JawsKeyAppend(nil, jawsKey)) + return assets.JawsKeyString(jawsKey) } // JawsKeyValue parses a key string (as returned JawsKeyString) into a uint64. func JawsKeyValue(jawsKey string) uint64 { - slashIdx := strings.IndexByte(jawsKey, '/') - if slashIdx < 0 { - slashIdx = len(jawsKey) - } - if val, err := strconv.ParseUint(jawsKey[:slashIdx], 32, 64); err == nil { - return val - } - return 0 + return assets.JawsKeyValue(jawsKey) } // PreloadHTML returns HTML code to load the given resources efficiently. func PreloadHTML(urls ...*url.URL) (htmlcode, faviconurl string) { - var jsurls, cssurls []string - var favicontype string - var buf []byte - for _, u := range urls { - if u != nil { - var asattr string - ext := strings.ToLower(filepath.Ext(u.Path)) - mimetype := mime.TypeByExtension(ext) - if semi := strings.IndexByte(mimetype, ';'); semi > 0 { - mimetype = mimetype[:semi] - } - urlstr := u.String() - switch ext { - case ".js": - jsurls = append(jsurls, urlstr) - continue - case ".css": - cssurls = append(cssurls, urlstr) - continue - default: - if strings.HasPrefix(mimetype, "image") { - asattr = "image" - if strings.HasPrefix(filepath.Base(u.Path), "favicon") { - favicontype = mimetype - faviconurl = urlstr - continue - } - } else if strings.HasPrefix(mimetype, "font") { - asattr = "font" - } - } - buf = append(buf, `\n"...) - } - } - for _, urlstr := range cssurls { - buf = append(buf, `\n"...) - } - if faviconurl != "" { - buf = append(buf, `\n"...) - } - for _, urlstr := range jsurls { - buf = append(buf, `\n"...) - } - htmlcode = string(buf) - return + return assets.PreloadHTML(urls...) } diff --git a/core/makehtmlgetter.go b/core/makehtmlgetter.go index 2bec0128..9665ca91 100644 --- a/core/makehtmlgetter.go +++ b/core/makehtmlgetter.go @@ -4,6 +4,8 @@ import ( "fmt" "html" "html/template" + + "github.com/linkdata/jaws/core/tags" ) type htmlGetter struct{ v template.HTML } @@ -12,7 +14,7 @@ func (g htmlGetter) JawsGetHTML(e *Element) template.HTML { return g.v } -func (g htmlGetter) JawsGetTag(rq *Request) any { +func (g htmlGetter) JawsGetTag(tags.Context) any { return nil } @@ -22,7 +24,7 @@ func (g htmlStringerGetter) JawsGetHTML(e *Element) template.HTML { return template.HTML(html.EscapeString(g.sg.String())) // #nosec G203 } -func (g htmlStringerGetter) JawsGetTag(rq *Request) any { +func (g htmlStringerGetter) JawsGetTag(tags.Context) any { return g.sg } @@ -38,7 +40,7 @@ func (g htmlGetterString) JawsGetHTML(e *Element) template.HTML { return template.HTML(html.EscapeString(g.sg.JawsGet(e))) // #nosec G203 } -func (g htmlGetterString) JawsGetTag(rq *Request) any { +func (g htmlGetterString) JawsGetTag(tags.Context) any { return g.sg } diff --git a/core/message.go b/core/message.go index 265ba1c1..aa76e689 100644 --- a/core/message.go +++ b/core/message.go @@ -1,23 +1,6 @@ package jaws -import ( - "fmt" - - "github.com/linkdata/jaws/what" -) +import "github.com/linkdata/jaws/core/wire" // Message contains the elements of a message to be sent to Requests. -type Message struct { - Dest any // destination (tag, html ID or *Element) - What what.What // what to change or do - Data string // data (e.g. inner HTML content or slice of tags) -} - -// String returns the Message in a form suitable for debug output. -func (msg *Message) String() string { - return fmt.Sprintf("{%v, %v, %q}", - msg.Dest, - msg.What, - msg.Data, - ) -} +type Message = wire.Message diff --git a/core/setter.go b/core/setter.go index 00f2e417..0444de44 100644 --- a/core/setter.go +++ b/core/setter.go @@ -2,6 +2,8 @@ package jaws import ( "fmt" + + "github.com/linkdata/jaws/core/tags" ) type Setter[T comparable] interface { @@ -18,7 +20,7 @@ func (setterReadOnly[T]) JawsSet(*Element, T) error { return ErrValueNotSettable } -func (s setterReadOnly[T]) JawsGetTag(*Request) any { +func (s setterReadOnly[T]) JawsGetTag(tags.Context) any { return s.Getter } @@ -34,7 +36,7 @@ func (s setterStatic[T]) JawsGet(*Element) T { return s.v } -func (s setterStatic[T]) JawsGetTag(*Request) any { +func (s setterStatic[T]) JawsGetTag(tags.Context) any { return nil } diff --git a/core/setterfloat64.go b/core/setterfloat64.go index de4c0254..d1c558c1 100644 --- a/core/setterfloat64.go +++ b/core/setterfloat64.go @@ -2,6 +2,8 @@ package jaws import ( "fmt" + + "github.com/linkdata/jaws/core/tags" ) type numeric interface { @@ -29,7 +31,7 @@ func (s setterFloat64[T]) JawsSet(e *Element, v float64) error { return s.Setter.JawsSet(e, T(v)) } -func (s setterFloat64[T]) JawsGetTag(*Request) any { +func (s setterFloat64[T]) JawsGetTag(tags.Context) any { return s.Setter } @@ -46,7 +48,7 @@ func (setterFloat64ReadOnly[T]) JawsSet(*Element, float64) error { return ErrValueNotSettable } -func (s setterFloat64ReadOnly[T]) JawsGetTag(*Request) any { +func (s setterFloat64ReadOnly[T]) JawsGetTag(tags.Context) any { return s.Getter } @@ -62,7 +64,7 @@ func (s setterFloat64Static[T]) JawsGet(*Element) float64 { return s.v } -func (s setterFloat64Static[T]) JawsGetTag(*Request) any { +func (s setterFloat64Static[T]) JawsGetTag(tags.Context) any { return nil } diff --git a/core/stringgetterfunc.go b/core/stringgetterfunc.go index 86ef5039..82eb54ac 100644 --- a/core/stringgetterfunc.go +++ b/core/stringgetterfunc.go @@ -1,5 +1,7 @@ package jaws +import "github.com/linkdata/jaws/core/tags" + type stringGetterFunc struct { fn func(*Element) string tags []any @@ -9,7 +11,7 @@ func (g *stringGetterFunc) JawsGet(e *Element) string { return g.fn(e) } -func (g *stringGetterFunc) JawsGetTag(e *Request) any { +func (g *stringGetterFunc) JawsGetTag(tags.Context) any { return g.tags } diff --git a/core/tag.go b/core/tag.go index aa7c3af8..6ef2fc2e 100644 --- a/core/tag.go +++ b/core/tag.go @@ -1,95 +1,19 @@ package jaws -import ( - "fmt" - "html/template" - "reflect" -) +import "github.com/linkdata/jaws/core/tags" -type Tag string +type Tag = tags.Tag -func TagString(tag any) string { - if rv := reflect.ValueOf(tag); rv.IsValid() { - if rv.Kind() == reflect.Pointer { - return fmt.Sprintf("%T(%p)", tag, tag) - } else if stringer, ok := tag.(fmt.Stringer); ok { - return fmt.Sprintf("%T(%s)", tag, stringer.String()) - } - } - return fmt.Sprintf("%#v", tag) -} - -type errTooManyTags struct{} - -func (errTooManyTags) Error() string { - return "too many tags" -} - -var ErrTooManyTags = errTooManyTags{} +var ErrTooManyTags = tags.ErrTooManyTags -func tagExpand(l int, rq *Request, tag any, result []any) ([]any, error) { - if l > 10 || len(result) > 100 { - return result, ErrTooManyTags - } - switch data := tag.(type) { - case string: - case template.HTML: - case template.HTMLAttr: - case int: - case int8: - case int16: - case int32: - case int64: - case uint: - case uint8: - case uint16: - case uint32: - case uint64: - case float32: - case float64: - case bool: - - case nil: - return result, nil - case []Tag: - for _, v := range data { - result = append(result, v) - } - return result, nil - case TagGetter: - newTag := data.JawsGetTag(rq) - if reflect.TypeOf(data) == reflect.TypeOf(newTag) { - if err := newErrNotUsableAsTag(newTag); err != nil { - return result, err - } - if data == newTag { - return append(result, data), nil - } - } - return tagExpand(l+1, rq, newTag, result) - case []any: - var err error - for _, v := range data { - if result, err = tagExpand(l+1, rq, v, result); err != nil { - break - } - } - return result, err - default: - if err := newErrNotUsableAsTag(data); err != nil { - return result, err - } - return append(result, data), nil - } - return result, errIllegalTagType{tag: tag} +func TagString(tag any) string { + return tags.TagString(tag) } func TagExpand(rq *Request, tag any) ([]any, error) { - return tagExpand(0, rq, tag, nil) + return tags.TagExpand(rq, tag) } func MustTagExpand(rq *Request, tag any) []any { - result, err := TagExpand(rq, tag) - rq.MustLog(err) - return result + return tags.MustTagExpand(rq, tag) } diff --git a/core/tag_test.go b/core/tag_test.go index cadab894..11f30b2b 100644 --- a/core/tag_test.go +++ b/core/tag_test.go @@ -10,18 +10,19 @@ import ( "testing" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/tags" ) type testSelfTagger struct { } -func (tt *testSelfTagger) JawsGetTag(rq *Request) any { +func (tt *testSelfTagger) JawsGetTag(tags.Context) any { return tt } type testBadTagGetter []int -func (tt testBadTagGetter) JawsGetTag(*Request) any { +func (tt testBadTagGetter) JawsGetTag(tags.Context) any { return tt } @@ -183,7 +184,7 @@ func TestTagExpand_NotUsableAsTag_WithNestedTagGetterHint(t *testing.T) { if !strings.Contains(err.Error(), "found nested TagGetter at Setter") { t.Fatalf("expected nested TagGetter search result in error text, got %q", err.Error()) } - if !strings.Contains(err.Error(), "implement JawsGetTag(*Request)") { + if !strings.Contains(err.Error(), "implement JawsGetTag(tags.Context)") { t.Fatalf("expected remediation hint in error text, got %q", err.Error()) } } diff --git a/core/taggetter.go b/core/taggetter.go index 68a75630..929c1700 100644 --- a/core/taggetter.go +++ b/core/taggetter.go @@ -1,5 +1,8 @@ package jaws -type TagGetter interface { - JawsGetTag(rq *Request) any // Note that the Request may be nil -} +import "github.com/linkdata/jaws/core/tags" + +// TagContext is passed to TagGetter.JawsGetTag when resolving dynamic tags. +type TagContext = tags.Context + +type TagGetter = tags.TagGetter diff --git a/core/tags/context.go b/core/tags/context.go new file mode 100644 index 00000000..acfb985d --- /dev/null +++ b/core/tags/context.go @@ -0,0 +1,7 @@ +package tags + +// Context can log or panic on tag expansion errors. +// A nil Context causes MustTagExpand to panic on non-nil errors. +type Context interface { + MustLog(err error) +} diff --git a/core/tags/errillegaltagtype.go b/core/tags/errillegaltagtype.go new file mode 100644 index 00000000..26901df8 --- /dev/null +++ b/core/tags/errillegaltagtype.go @@ -0,0 +1,18 @@ +package tags + +import "fmt" + +// ErrIllegalTagType is returned when a UI tag type is disallowed. +var ErrIllegalTagType errIllegalTagType + +type errIllegalTagType struct { + tag any +} + +func (e errIllegalTagType) Error() string { + return fmt.Sprintf("illegal tag type %T", e.tag) +} + +func (errIllegalTagType) Is(other error) bool { + return other == ErrIllegalTagType +} diff --git a/core/tags/errnotcomparable.go b/core/tags/errnotcomparable.go new file mode 100644 index 00000000..e19d8eda --- /dev/null +++ b/core/tags/errnotcomparable.go @@ -0,0 +1,31 @@ +package tags + +import "reflect" + +// ErrNotComparable is returned when a UI object or tag is not comparable. +var ErrNotComparable errNotComparable + +type errNotComparable struct { + t reflect.Type +} + +func (e errNotComparable) Error() (s string) { + if e.t != nil { + s = e.t.String() + " is " + } + return s + "not comparable" +} + +func (errNotComparable) Is(target error) bool { + return target == ErrNotComparable +} + +// NewErrNotComparable returns ErrNotComparable if x is not comparable. +func NewErrNotComparable(x any) error { + if x != nil { + if v := reflect.ValueOf(x); !v.Comparable() { + return errNotComparable{t: reflect.TypeOf(x)} + } + } + return nil +} diff --git a/core/tags/errnotusableastag.go b/core/tags/errnotusableastag.go new file mode 100644 index 00000000..fbe54a17 --- /dev/null +++ b/core/tags/errnotusableastag.go @@ -0,0 +1,136 @@ +package tags + +import ( + "fmt" + "reflect" + "strconv" +) + +// ErrNotUsableAsTag is returned when a value cannot be used as a tag. +// +// It is also matchable as ErrNotComparable for backwards compatibility. +var ErrNotUsableAsTag errNotUsableAsTag + +type errNotUsableAsTag struct { + t reflect.Type + tagGetterPath string + tagGetterType reflect.Type +} + +func (e errNotUsableAsTag) Error() (s string) { + if e.t != nil { + s = e.t.String() + " is " + } + s += "not usable as tag" + if e.tagGetterType != nil { + return s + fmt.Sprintf("; found nested TagGetter at %s (%s); hint: implement JawsGetTag(tags.Context) on this type to delegate to that value, or pass that nested TagGetter directly", e.tagGetterPath, e.tagGetterType) + } + return s + "; found no nested TagGetter; hint: use a comparable tag value, or implement JawsGetTag(tags.Context) and return a comparable tag" +} + +func (errNotUsableAsTag) Is(target error) bool { + return target == ErrNotUsableAsTag || target == ErrNotComparable +} + +// NewErrNotUsableAsTag returns ErrNotUsableAsTag if x cannot be used as a tag. +func NewErrNotUsableAsTag(x any) error { + if err := NewErrNotComparable(x); err != nil { + retErr := errNotUsableAsTag{t: reflect.TypeOf(x)} + if path, tgType, ok := FindTagGetter(x); ok { + retErr.tagGetterPath = path + retErr.tagGetterType = tgType + } + return retErr + } + return nil +} + +var tagGetterType = reflect.TypeFor[TagGetter]() + +// FindTagGetter searches x recursively for a nested TagGetter. +func FindTagGetter(x any) (path string, tgType reflect.Type, found bool) { + if x == nil { + return + } + type seenPtr struct { + t reflect.Type + ptr uintptr + } + seen := map[seenPtr]struct{}{} + var walk func(v reflect.Value, currentPath string, depth int) bool + walk = func(v reflect.Value, currentPath string, depth int) bool { + if !v.IsValid() || depth > 10 { + return false + } + t := v.Type() + if t.Implements(tagGetterType) { + path = currentPath + if path == "" { + path = "" + } + tgType = t + found = true + return true + } + switch v.Kind() { + case reflect.Interface: + if v.IsNil() { + return false + } + return walk(v.Elem(), currentPath, depth+1) + case reflect.Pointer: + if v.IsNil() { + return false + } + p := seenPtr{t: t, ptr: v.Pointer()} + if _, ok := seen[p]; ok { + return false + } + seen[p] = struct{}{} + return walk(v.Elem(), currentPath, depth+1) + case reflect.Struct: + for i := range t.NumField() { + next := t.Field(i).Name + if currentPath != "" { + next = currentPath + "." + next + } + if walk(v.Field(i), next, depth+1) { + return true + } + } + case reflect.Array: + n := min(v.Len(), 4) + for i := range n { + next := "[" + strconv.Itoa(i) + "]" + if currentPath != "" { + next = currentPath + next + } + if walk(v.Index(i), next, depth+1) { + return true + } + } + case reflect.Slice: + if v.IsNil() { + return false + } + p := seenPtr{t: t, ptr: v.Pointer()} + if _, ok := seen[p]; ok { + return false + } + seen[p] = struct{}{} + n := min(v.Len(), 4) + for i := range n { + next := "[" + strconv.Itoa(i) + "]" + if currentPath != "" { + next = currentPath + next + } + if walk(v.Index(i), next, depth+1) { + return true + } + } + } + return false + } + walk(reflect.ValueOf(x), path, 0) + return +} diff --git a/core/tags/tag.go b/core/tags/tag.go new file mode 100644 index 00000000..678cde96 --- /dev/null +++ b/core/tags/tag.go @@ -0,0 +1,100 @@ +package tags + +import ( + "fmt" + "html/template" + "reflect" +) + +type Tag string + +func TagString(tag any) string { + if rv := reflect.ValueOf(tag); rv.IsValid() { + if rv.Kind() == reflect.Pointer { + return fmt.Sprintf("%T(%p)", tag, tag) + } else if stringer, ok := tag.(fmt.Stringer); ok { + return fmt.Sprintf("%T(%s)", tag, stringer.String()) + } + } + return fmt.Sprintf("%#v", tag) +} + +type errTooManyTags struct{} + +func (errTooManyTags) Error() string { + return "too many tags" +} + +var ErrTooManyTags = errTooManyTags{} + +func tagExpand(l int, ctx Context, tag any, result []any) ([]any, error) { + if l > 10 || len(result) > 100 { + return result, ErrTooManyTags + } + switch data := tag.(type) { + case string: + case template.HTML: + case template.HTMLAttr: + case int: + case int8: + case int16: + case int32: + case int64: + case uint: + case uint8: + case uint16: + case uint32: + case uint64: + case float32: + case float64: + case bool: + case nil: + return result, nil + case []Tag: + for _, v := range data { + result = append(result, v) + } + return result, nil + case TagGetter: + newTag := data.JawsGetTag(ctx) + if reflect.TypeOf(data) == reflect.TypeOf(newTag) { + if err := NewErrNotUsableAsTag(newTag); err != nil { + return result, err + } + if data == newTag { + return append(result, data), nil + } + } + return tagExpand(l+1, ctx, newTag, result) + case []any: + var err error + for _, v := range data { + if result, err = tagExpand(l+1, ctx, v, result); err != nil { + break + } + } + return result, err + default: + if err := NewErrNotUsableAsTag(data); err != nil { + return result, err + } + return append(result, data), nil + } + return result, errIllegalTagType{tag: tag} +} + +func TagExpand(ctx Context, tag any) ([]any, error) { + return tagExpand(0, ctx, tag, nil) +} + +func MustTagExpand(ctx Context, tag any) []any { + result, err := TagExpand(ctx, tag) + if err != nil { + if ctx != nil { + ctx.MustLog(err) + } else { + panic(err) + } + } + return result +} diff --git a/core/tags/taggetter.go b/core/tags/taggetter.go new file mode 100644 index 00000000..da0749aa --- /dev/null +++ b/core/tags/taggetter.go @@ -0,0 +1,5 @@ +package tags + +type TagGetter interface { + JawsGetTag(ctx Context) any // Note that the Context may be nil +} diff --git a/core/wire/message.go b/core/wire/message.go new file mode 100644 index 00000000..fc1385f0 --- /dev/null +++ b/core/wire/message.go @@ -0,0 +1,23 @@ +package wire + +import ( + "fmt" + + "github.com/linkdata/jaws/what" +) + +// Message contains the elements of a message to be sent to requests. +type Message struct { + Dest any // destination (tag, html ID or *Element) + What what.What // what to change or do + Data string // data (e.g. inner HTML content or slice of tags) +} + +// String returns the Message in a form suitable for debug output. +func (msg *Message) String() string { + return fmt.Sprintf("{%v, %v, %q}", + msg.Dest, + msg.What, + msg.Data, + ) +} diff --git a/core/wire/wsmsg.go b/core/wire/wsmsg.go new file mode 100644 index 00000000..71ac1c18 --- /dev/null +++ b/core/wire/wsmsg.go @@ -0,0 +1,79 @@ +package wire + +import ( + "bytes" + "html" + "strconv" + "strings" + + "github.com/linkdata/jaws/jid" + "github.com/linkdata/jaws/what" +) + +// WsMsg is a message sent to or from a WebSocket. +type WsMsg struct { + Data string // data to send + Jid jid.Jid // Jid to send, or -1 if Data contains that already + What what.What // command +} + +func (m *WsMsg) Append(b []byte) []byte { + b = append(b, m.What.String()...) + b = append(b, '\t') + if m.Jid >= 0 { + if m.Jid > 0 { + b = m.Jid.Append(b) + } + b = append(b, '\t') + switch m.What { + case what.Set, what.Call: + b = append(b, m.Data...) + default: + b = strconv.AppendQuote(b, m.Data) + } + } else { + b = append(b, m.Data...) + } + b = append(b, '\n') + return b +} + +func (m *WsMsg) Format() string { + return string(m.Append(nil)) +} + +// Parse parses an incoming text buffer into a message. +func Parse(txt []byte) (WsMsg, bool) { + if len(txt) > 2 && txt[len(txt)-1] == '\n' { + if nl1 := bytes.IndexByte(txt, '\t'); nl1 >= 0 { + if nl2 := bytes.IndexByte(txt[nl1+1:], '\t'); nl2 >= 0 { + nl2 += nl1 + 1 + // What ... Jid ... Data ... EOL + // txt[0:nl1] ... txt[nl1+1 : nl2] ... txt[nl2+1:len(txt)-1] ... \n + if wht := what.Parse(string(txt[0:nl1])); wht.IsValid() { + if id := jid.ParseString(string(txt[nl1+1 : nl2])); id.IsValid() { + data := string(txt[nl2+1 : len(txt)-1]) + if txt[nl2+1] == '"' && wht != what.Set && wht != what.Call { + var err error + if data, err = strconv.Unquote(data); err != nil { + return WsMsg{}, false + } + } + return WsMsg{ + Data: strings.ToValidUTF8(data, ""), + Jid: id, + What: wht, + }, true + } + } + } + } + } + return WsMsg{}, false +} + +func (m *WsMsg) FillAlert(err error) { + m.Jid = 0 + m.What = what.Alert + m.Data = "danger\n" + html.EscapeString(err.Error()) +} diff --git a/core/wsmsg.go b/core/wsmsg.go index 07a82201..69fef24d 100644 --- a/core/wsmsg.go +++ b/core/wsmsg.go @@ -1,79 +1,11 @@ package jaws -import ( - "bytes" - "html" - "strconv" - "strings" - - "github.com/linkdata/jaws/jid" - "github.com/linkdata/jaws/what" -) +import "github.com/linkdata/jaws/core/wire" // WsMsg is a message sent to or from a WebSocket. -type WsMsg struct { - Data string // data to send - Jid Jid // Jid to send, or -1 if Data contains that already - What what.What // command -} - -func (m *WsMsg) Append(b []byte) []byte { - b = append(b, m.What.String()...) - b = append(b, '\t') - if m.Jid >= 0 { - if m.Jid > 0 { - b = m.Jid.Append(b) - } - b = append(b, '\t') - switch m.What { - case what.Set, what.Call: - b = append(b, m.Data...) - default: - b = strconv.AppendQuote(b, m.Data) - } - } else { - b = append(b, m.Data...) - } - b = append(b, '\n') - return b -} - -func (m *WsMsg) Format() string { - return string(m.Append(nil)) -} +type WsMsg = wire.WsMsg // wsParse parses an incoming text buffer into a message. func wsParse(txt []byte) (WsMsg, bool) { - if len(txt) > 2 && txt[len(txt)-1] == '\n' { - if nl1 := bytes.IndexByte(txt, '\t'); nl1 >= 0 { - if nl2 := bytes.IndexByte(txt[nl1+1:], '\t'); nl2 >= 0 { - nl2 += nl1 + 1 - // What ... Jid ... Data ... EOL - // txt[0:nl1] ... txt[nl1+1 : nl2] ... txt[nl2+1:len(txt)-1] ... \n - if wht := what.Parse(string(txt[0:nl1])); wht.IsValid() { - if id := jid.ParseString(string(txt[nl1+1 : nl2])); id.IsValid() { - data := string(txt[nl2+1 : len(txt)-1]) - if txt[nl2+1] == '"' && wht != what.Set && wht != what.Call { - var err error - if data, err = strconv.Unquote(data); err != nil { - return WsMsg{}, false - } - } - return WsMsg{ - Data: strings.ToValidUTF8(data, ""), - Jid: id, - What: wht, - }, true - } - } - } - } - } - return WsMsg{}, false -} - -func (m *WsMsg) FillAlert(err error) { - m.Jid = 0 - m.What = what.Alert - m.Data = "danger\n" + html.EscapeString(err.Error()) + return wire.Parse(txt) } diff --git a/ui/html_widgets_test.go b/ui/html_widgets_test.go index dbaf2b0f..db3176e1 100644 --- a/ui/html_widgets_test.go +++ b/ui/html_widgets_test.go @@ -7,6 +7,7 @@ import ( "testing" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/tags" ) func TestHTMLWidgets_ConstructorsAndRender(t *testing.T) { @@ -53,7 +54,7 @@ type initFailGetter struct { } func (g *initFailGetter) JawsGetHTML(*core.Element) template.HTML { return "x" } -func (g *initFailGetter) JawsGetTag(*core.Request) any { return g } +func (g *initFailGetter) JawsGetTag(tags.Context) any { return g } func (g *initFailGetter) JawsInit(*core.Element) error { return g.err } func TestImg_RenderAndUpdate(t *testing.T) { diff --git a/ui/jsvar.go b/ui/jsvar.go index 0ae26d07..223c575e 100644 --- a/ui/jsvar.go +++ b/ui/jsvar.go @@ -12,6 +12,7 @@ import ( "sync" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/what" "github.com/linkdata/jq" ) @@ -177,7 +178,7 @@ func (ui *JsVar[T]) JawsRender(e *core.Element, w io.Writer, params []any) (err return } -func (ui *JsVar[T]) JawsGetTag(rq *core.Request) any { +func (ui *JsVar[T]) JawsGetTag(tags.Context) any { return ui.Tag } diff --git a/ui/requestwriter_test.go b/ui/requestwriter_test.go index 3d3728d0..a0311cef 100644 --- a/ui/requestwriter_test.go +++ b/ui/requestwriter_test.go @@ -11,6 +11,7 @@ import ( "time" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/tags" ) type testRWUpdater struct { @@ -26,7 +27,7 @@ type requestWriterFailGetter struct { } func (g requestWriterFailGetter) JawsGetHTML(*core.Element) template.HTML { return "x" } -func (g requestWriterFailGetter) JawsGetTag(*core.Request) any { return g } +func (g requestWriterFailGetter) JawsGetTag(tags.Context) any { return g } func (g requestWriterFailGetter) JawsInit(*core.Element) error { return g.err } func newSessionBoundRequest(t *testing.T) (*core.Jaws, *core.Request) { From 70f61f93cc0ade8a47df07fb3a86dab364443285 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 13:36:15 +0200 Subject: [PATCH 02/41] moving --- core/{ => assets}/defaultcookiename_test.go | 4 ++-- core/dateformat.go | 6 ------ core/defaultcookiename.go | 11 ----------- core/jaws.go | 3 ++- jaws.go | 3 ++- ui/input_widgets.go | 5 +++-- ui/input_widgets_test.go | 7 ++++--- 7 files changed, 13 insertions(+), 26 deletions(-) rename core/{ => assets}/defaultcookiename_test.go (86%) delete mode 100644 core/dateformat.go delete mode 100644 core/defaultcookiename.go diff --git a/core/defaultcookiename_test.go b/core/assets/defaultcookiename_test.go similarity index 86% rename from core/defaultcookiename_test.go rename to core/assets/defaultcookiename_test.go index 584be928..79fac893 100644 --- a/core/defaultcookiename_test.go +++ b/core/assets/defaultcookiename_test.go @@ -1,4 +1,4 @@ -package jaws +package assets import ( "path" @@ -19,7 +19,7 @@ func Test_makeCookieName(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if gotCookie := makeCookieName(tt.exename); gotCookie != tt.wantCookie { + if gotCookie := MakeCookieName(tt.exename); gotCookie != tt.wantCookie { t.Errorf("makeCookieName() = %v, want %v", gotCookie, tt.wantCookie) } }) diff --git a/core/dateformat.go b/core/dateformat.go deleted file mode 100644 index 89566d49..00000000 --- a/core/dateformat.go +++ /dev/null @@ -1,6 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/assets" - -// ISO8601 is the date format used by date input widgets (YYYY-MM-DD). -const ISO8601 = assets.ISO8601 diff --git a/core/defaultcookiename.go b/core/defaultcookiename.go deleted file mode 100644 index d94c879b..00000000 --- a/core/defaultcookiename.go +++ /dev/null @@ -1,11 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/assets" - -// DefaultCookieName holds the default JaWS cookie name. -// It will be generated from the executable name, or "jaws" if that fails. -var DefaultCookieName = assets.DefaultCookieName - -func makeCookieName(exename string) (cookie string) { - return assets.MakeCookieName(exename) -} diff --git a/core/jaws.go b/core/jaws.go index b4ec9279..9cba77e0 100644 --- a/core/jaws.go +++ b/core/jaws.go @@ -32,6 +32,7 @@ import ( "time" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/secureheaders" "github.com/linkdata/jaws/staticserve" @@ -91,7 +92,7 @@ func New() (jw *Jaws, err error) { if serveJS, err = staticserve.New("/jaws/.jaws.js", JavascriptText); err == nil { if serveCSS, err = staticserve.New("/jaws/.jaws.css", JawsCSS); err == nil { tmp := &Jaws{ - CookieName: DefaultCookieName, + CookieName: assets.DefaultCookieName, BaseContext: context.Background(), serveJS: serveJS, serveCSS: serveCSS, diff --git a/jaws.go b/jaws.go index 03811ee9..6ca6532e 100644 --- a/jaws.go +++ b/jaws.go @@ -5,6 +5,7 @@ import ( "time" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/ui" ) @@ -97,7 +98,7 @@ var ( const ( // ISO8601 is the date format used by date input widgets (YYYY-MM-DD). - ISO8601 = core.ISO8601 + ISO8601 = assets.ISO8601 ) var ( diff --git a/ui/input_widgets.go b/ui/input_widgets.go index 22f52eb3..afef44f6 100644 --- a/ui/input_widgets.go +++ b/ui/input_widgets.go @@ -7,6 +7,7 @@ import ( "time" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/what" ) @@ -148,7 +149,7 @@ type InputDate struct { } func (ui *InputDate) str(v time.Time) string { - return v.Format(core.ISO8601) + return v.Format(assets.ISO8601) } func (ui *InputDate) renderDateInput(e *core.Element, w io.Writer, htmlType string, params ...any) (err error) { @@ -175,7 +176,7 @@ func (ui *InputDate) JawsEvent(e *core.Element, wht what.What, val string) (err val = "0001-01-01" } var v time.Time - if v, err = time.Parse(core.ISO8601, val); err == nil { + if v, err = time.Parse(assets.ISO8601, val); err == nil { err = ui.maybeDirty(e, ui.Setter.JawsSet(e, v)) } ui.Last.Store(v) diff --git a/ui/input_widgets_test.go b/ui/input_widgets_test.go index 848d57a7..e98ae787 100644 --- a/ui/input_widgets_test.go +++ b/ui/input_widgets_test.go @@ -6,6 +6,7 @@ import ( "time" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/what" ) @@ -106,7 +107,7 @@ func TestInputFloatWidgets(t *testing.T) { func TestInputDateWidget(t *testing.T) { _, rq := newRequest(t) - d0, _ := time.Parse(core.ISO8601, "2020-01-02") + d0, _ := time.Parse(assets.ISO8601, "2020-01-02") sd := newTestSetter(d0) date := NewDate(sd) @@ -116,7 +117,7 @@ func TestInputDateWidget(t *testing.T) { if err := date.JawsEvent(elem, what.Input, "2021-02-03"); err != nil { t.Fatal(err) } - if sd.Get().Format(core.ISO8601) != "2021-02-03" { + if sd.Get().Format(assets.ISO8601) != "2021-02-03" { t.Fatalf("unexpected date %v", sd.Get()) } if err := date.JawsEvent(elem, what.Input, ""); err != nil { @@ -128,7 +129,7 @@ func TestInputDateWidget(t *testing.T) { if err := date.JawsEvent(elem, what.Input, "bad"); err == nil { t.Fatal("expected parse error") } - d1, _ := time.Parse(core.ISO8601, "2022-03-04") + d1, _ := time.Parse(assets.ISO8601, "2022-03-04") sd.Set(d1) date.JawsUpdate(elem) } From a7cc56e1ffa7cc1bffda6d0e04ca6c3ad8269352 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 14:03:41 +0200 Subject: [PATCH 03/41] wip --- core/{ => assets}/js_test.go | 104 ++--- core/binder.go | 4 +- core/element.go | 8 +- core/element_test.go | 16 +- core/errillegaltagtype.go | 6 - core/errnotcomparable.go | 10 - core/errnotusableastag.go | 20 - core/errpendingcancelled.go | 4 +- core/htmlgetterfunc.go | 2 +- core/jaws.css | 16 - core/jaws.go | 78 ++-- core/jaws.js | 479 ---------------------- core/js.go | 33 -- core/message.go | 6 - core/parseparams.go | 10 +- core/request.go | 61 +-- core/servehttp.go | 6 +- core/session.go | 10 +- core/subscription.go | 4 +- core/tag.go | 19 - core/tag_testsupport_test.go | 28 ++ core/taggetter.go | 8 - core/{ => tags}/errnotcomparable_test.go | 6 +- core/{ => tags}/errnotusableastag_test.go | 16 +- core/{ => tags}/tag_test.go | 31 +- core/testsupport.go | 12 +- core/{ => wire}/message_test.go | 2 +- core/{ => wire}/wsmsg_test.go | 29 +- core/ws.go | 15 +- core/wsmsg.go | 11 - jaws.go | 15 +- jawstest/jsvar_test.go | 8 +- ui/jsvar.go | 3 +- ui/template.go | 5 +- ui/template_handler_test.go | 7 +- 35 files changed, 276 insertions(+), 816 deletions(-) rename core/{ => assets}/js_test.go (68%) delete mode 100644 core/errillegaltagtype.go delete mode 100644 core/errnotcomparable.go delete mode 100644 core/errnotusableastag.go delete mode 100644 core/jaws.css delete mode 100644 core/jaws.js delete mode 100644 core/js.go delete mode 100644 core/message.go delete mode 100644 core/tag.go create mode 100644 core/tag_testsupport_test.go delete mode 100644 core/taggetter.go rename core/{ => tags}/errnotcomparable_test.go (83%) rename core/{ => tags}/errnotusableastag_test.go (84%) rename core/{ => tags}/tag_test.go (93%) rename core/{ => wire}/message_test.go (96%) rename core/{ => wire}/wsmsg_test.go (81%) delete mode 100644 core/wsmsg.go diff --git a/core/js_test.go b/core/assets/js_test.go similarity index 68% rename from core/js_test.go rename to core/assets/js_test.go index 6284ffaa..fe1d0877 100644 --- a/core/js_test.go +++ b/core/assets/js_test.go @@ -1,8 +1,7 @@ -package jaws +package assets import ( "bytes" - _ "embed" "net/url" "os/exec" "path/filepath" @@ -10,6 +9,7 @@ import ( "strings" "testing" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/staticserve" "github.com/linkdata/jaws/what" ) @@ -19,17 +19,24 @@ func Test_PreloadHTML(t *testing.T) { const extraStyle = "someExtraStyle.css" const extraImage = "favicon.png" const extraFont = "someExtraFont.woff2" - th := newTestHelper(t) serveJS, err := staticserve.New("/jaws/.jaws.js", JavascriptText) - th.NoErr(err) + if err != nil { + t.Fatal(err) + } txt, fav := PreloadHTML() - th.Equal(strings.Contains(txt, serveJS.Name), false) - th.Equal(strings.Count(txt, "")) - th.Equal(fav, "") + if strings.Contains(txt, serveJS.Name) { + t.Fatalf("unexpected preload output contains %q: %q", serveJS.Name, txt) + } + if strings.Count(txt, "") { + t.Fatalf("script tags are unbalanced: %q", txt) + } + if fav != "" { + t.Fatalf("unexpected favicon %q", fav) + } - mustParseUrl := func(urlstr string) *url.URL { + mustParseURL := func(urlstr string) *url.URL { u, err := url.Parse(urlstr) if err != nil { t.Fatal(err) @@ -38,25 +45,42 @@ func Test_PreloadHTML(t *testing.T) { } txt, fav = PreloadHTML( - mustParseUrl(serveJS.Name), - mustParseUrl(extraScript), - mustParseUrl(extraStyle), - mustParseUrl(extraImage), - mustParseUrl(extraFont)) - th.Equal(strings.Contains(txt, serveJS.Name), true) - th.Equal(strings.Contains(txt, extraScript), true) - th.Equal(strings.Contains(txt, extraStyle), true) - th.Equal(strings.Contains(txt, extraImage), true) - th.Equal(strings.Contains(txt, extraFont), true) - th.Equal(strings.Count(txt, "")) - th.Equal(fav, extraImage) - t.Log(txt) + mustParseURL(serveJS.Name), + mustParseURL(extraScript), + mustParseURL(extraStyle), + mustParseURL(extraImage), + mustParseURL(extraFont), + ) + if !strings.Contains(txt, serveJS.Name) { + t.Fatalf("missing %q in preload output: %q", serveJS.Name, txt) + } + if !strings.Contains(txt, extraScript) { + t.Fatalf("missing %q in preload output: %q", extraScript, txt) + } + if !strings.Contains(txt, extraStyle) { + t.Fatalf("missing %q in preload output: %q", extraStyle, txt) + } + if !strings.Contains(txt, extraImage) { + t.Fatalf("missing %q in preload output: %q", extraImage, txt) + } + if !strings.Contains(txt, extraFont) { + t.Fatalf("missing %q in preload output: %q", extraFont, txt) + } + if strings.Count(txt, "") { + t.Fatalf("script tags are unbalanced: %q", txt) + } + if fav != extraImage { + t.Fatalf("favicon = %q, want %q", fav, extraImage) + } } func TestJawsKeyString(t *testing.T) { - th := newTestHelper(t) - th.Equal(JawsKeyString(0), "") - th.Equal(JawsKeyString(1), "1") + if got := JawsKeyString(0); got != "" { + t.Fatalf("JawsKeyString(0) = %q, want empty", got) + } + if got := JawsKeyString(1); got != "1" { + t.Fatalf("JawsKeyString(1) = %q, want %q", got, "1") + } } func TestJawsKeyValue(t *testing.T) { @@ -65,26 +89,10 @@ func TestJawsKeyValue(t *testing.T) { jawsKey string want uint64 }{ - { - name: "blank", - jawsKey: "", - want: 0, - }, - { - name: "1", - jawsKey: "1", - want: 1, - }, - { - name: "-1", - jawsKey: "-1", - want: 0, - }, - { - name: "2/", - jawsKey: "2/", - want: 2, - }, + {name: "blank", jawsKey: "", want: 0}, + {name: "1", jawsKey: "1", want: 1}, + {name: "-1", jawsKey: "-1", want: 0}, + {name: "2/", jawsKey: "2/", want: 2}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -161,9 +169,9 @@ process.stdout.write(jaws.sent[0] || ""); t.Fatal("jawsVar did not emit a websocket frame") } - msg, ok := wsParse([]byte(raw)) + msg, ok := wire.Parse([]byte(raw)) if !ok { - t.Fatalf("Set frame must be parseable by core wsParse, got %q", raw) + t.Fatalf("Set frame must be parseable by wire.Parse, got %q", raw) } if msg.What != what.Set { t.Fatalf("unexpected what: got %v", msg.What) @@ -197,7 +205,7 @@ process.stdout.write(jaws.sent[0] || ""); t.Fatal("jawsRemoving did not emit a websocket frame") } - if msg, ok := wsParse([]byte(raw)); ok { + if msg, ok := wire.Parse([]byte(raw)); ok { t.Fatalf("expected invalid untrusted Remove frame to be dropped by parser, got %+v from %q", msg, raw) } } @@ -216,7 +224,7 @@ process.stdout.write(jaws.sent[0] || ""); `) if raw != "" { - if _, ok := wsParse([]byte(raw)); !ok { + if _, ok := wire.Parse([]byte(raw)); !ok { t.Fatalf("jawsVar should not emit unparseable Set frame when JsVar name is unregistered, got %q", raw) } } diff --git a/core/binder.go b/core/binder.go index fed2b54b..32ce502e 100644 --- a/core/binder.go +++ b/core/binder.go @@ -1,5 +1,7 @@ package jaws +import "github.com/linkdata/jaws/core/tags" + // BindSetHook is a function that replaces JawsSetLocked for a Binder. // // The lock will be held before calling the function, preferring RLock over Lock, if available. @@ -40,7 +42,7 @@ type Formatter interface { type Binder[T comparable] interface { RWLocker Setter[T] - TagGetter + tags.TagGetter Formatter ClickHandler diff --git a/core/element.go b/core/element.go index 3d6034cc..2b29ac90 100644 --- a/core/element.go +++ b/core/element.go @@ -9,6 +9,8 @@ import ( "strings" "sync/atomic" + "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/what" ) @@ -76,7 +78,7 @@ func (e *Element) renderDebug(w io.Writer) { if i > 0 { sb.WriteString(", ") } - sb.WriteString(TagString(tag)) + sb.WriteString(tags.TagString(tag)) } } else { sb.WriteString("n/a") @@ -110,7 +112,7 @@ func (e *Element) JawsUpdate() { func (e *Element) queue(wht what.What, data string) { if !e.deleted.Load() { - e.Request.queue(WsMsg{ + e.Request.queue(wire.WsMsg{ Data: data, Jid: e.jid, What: wht, @@ -244,7 +246,7 @@ func (e *Element) ApplyParams(params []any) (retv []template.HTMLAttr) { func (e *Element) ApplyGetter(getter any) (tag any, err error) { if getter != nil { tag = getter - if tagger, ok := getter.(TagGetter); ok { + if tagger, ok := getter.(tags.TagGetter); ok { tag = tagger.JawsGetTag(e.Request) } if eh, ok := getter.(EventHandler); ok { diff --git a/core/element_test.go b/core/element_test.go index 0e59bafb..28f9948b 100644 --- a/core/element_test.go +++ b/core/element_test.go @@ -261,9 +261,9 @@ func TestElement_maybeDirty(t *testing.T) { th.Equal(changed, false) th.Equal(err, nil) - changed, err = e.maybeDirty(e, ErrNotComparable) + changed, err = e.maybeDirty(e, tags.ErrNotComparable) th.Equal(changed, false) - th.Equal(err, ErrNotComparable) + th.Equal(err, tags.ErrNotComparable) } func TestElement_RenderDebugAndDeletedBranches(t *testing.T) { @@ -315,15 +315,15 @@ func TestElement_ApplyGetterDebugBranches(t *testing.T) { } ag := testApplyGetterAll{} - tags, err := elem.ApplyGetter(ag) + gotTags, err := elem.ApplyGetter(ag) if err != nil { t.Fatalf("unexpected error %v", err) } if !elem.HasTag(Tag("tg")) { - t.Fatalf("missing Tag('tg') in %#v", tags) + t.Fatalf("missing Tag('tg') in %#v", gotTags) } - agErr := testApplyGetterAll{initErr: ErrNotComparable} - if _, err := elem.ApplyGetter(agErr); err != ErrNotComparable { + agErr := testApplyGetterAll{initErr: tags.ErrNotComparable} + if _, err := elem.ApplyGetter(agErr); err != tags.ErrNotComparable { t.Fatalf("expected init err, got %v", err) } @@ -487,7 +487,7 @@ func TestElement_JawsInit(t *testing.T) { defer rq.Close() tss := &testUi{s: "foo"} - tss.initError = ErrNotComparable + tss.initError = tags.ErrNotComparable e := rq.NewElement(tss) tag, err := e.ApplyGetter(tss) @@ -495,7 +495,7 @@ func TestElement_JawsInit(t *testing.T) { if tag != tss { t.Errorf("tag was %#v", tag) } - if err != ErrNotComparable { + if err != tags.ErrNotComparable { t.Error(err) } } diff --git a/core/errillegaltagtype.go b/core/errillegaltagtype.go deleted file mode 100644 index e2a84f40..00000000 --- a/core/errillegaltagtype.go +++ /dev/null @@ -1,6 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/tags" - -// ErrIllegalTagType is returned when a UI tag type is disallowed -var ErrIllegalTagType = tags.ErrIllegalTagType diff --git a/core/errnotcomparable.go b/core/errnotcomparable.go deleted file mode 100644 index 4d1977e5..00000000 --- a/core/errnotcomparable.go +++ /dev/null @@ -1,10 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/tags" - -// ErrNotComparable is returned when a UI object or tag is not comparable. -var ErrNotComparable = tags.ErrNotComparable - -func newErrNotComparable(x any) error { - return tags.NewErrNotComparable(x) -} diff --git a/core/errnotusableastag.go b/core/errnotusableastag.go deleted file mode 100644 index 674e6bd6..00000000 --- a/core/errnotusableastag.go +++ /dev/null @@ -1,20 +0,0 @@ -package jaws - -import ( - "reflect" - - "github.com/linkdata/jaws/core/tags" -) - -// ErrNotUsableAsTag is returned when a value cannot be used as a tag. -// -// It is also matchable as ErrNotComparable for backwards compatibility. -var ErrNotUsableAsTag = tags.ErrNotUsableAsTag - -func newErrNotUsableAsTag(x any) error { - return tags.NewErrNotUsableAsTag(x) -} - -func findTagGetter(x any) (path string, tgType reflect.Type, found bool) { - return tags.FindTagGetter(x) -} diff --git a/core/errpendingcancelled.go b/core/errpendingcancelled.go index 133d3e8c..eba2fcc8 100644 --- a/core/errpendingcancelled.go +++ b/core/errpendingcancelled.go @@ -2,6 +2,8 @@ package jaws import ( "fmt" + + "github.com/linkdata/jaws/core/assets" ) // ErrPendingCancelled indicates a pending Request was cancelled. Use Unwrap() to see the underlying cause. @@ -14,7 +16,7 @@ type errPendingCancelled struct { } func (e errPendingCancelled) Error() string { - return fmt.Sprintf("Request<%s>:%s %v", JawsKeyString(e.JawsKey), e.Initial, e.Cause) + return fmt.Sprintf("Request<%s>:%s %v", assets.JawsKeyString(e.JawsKey), e.Initial, e.Cause) } func (e errPendingCancelled) Is(target error) (yes bool) { diff --git a/core/htmlgetterfunc.go b/core/htmlgetterfunc.go index 9a38f68f..8765da1c 100644 --- a/core/htmlgetterfunc.go +++ b/core/htmlgetterfunc.go @@ -11,7 +11,7 @@ type htmlGetterFunc struct { tags []any } -var _ TagGetter = &htmlGetterFunc{} +var _ tags.TagGetter = &htmlGetterFunc{} func (g *htmlGetterFunc) JawsGetHTML(e *Element) template.HTML { return g.fn(e) diff --git a/core/jaws.css b/core/jaws.css deleted file mode 100644 index 40e471f6..00000000 --- a/core/jaws.css +++ /dev/null @@ -1,16 +0,0 @@ -.jaws-lost { - display: flex; - position: relative; - z-index: 1000; - height: 3em; - width: 100vw; - left: 50%; - margin-left: -50vw; - right: 50%; - margin-right:-50vw; - justify-content: center; - align-items: center; - background-color: red; - color: white; -} - diff --git a/core/jaws.go b/core/jaws.go index 9cba77e0..52dbdbc0 100644 --- a/core/jaws.go +++ b/core/jaws.go @@ -33,6 +33,8 @@ import ( "github.com/linkdata/deadlock" "github.com/linkdata/jaws/core/assets" + "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/secureheaders" "github.com/linkdata/jaws/staticserve" @@ -62,9 +64,9 @@ type Jaws struct { Debug bool // Set to true to enable debug info in generated HTML code MakeAuth MakeAuthFn // Optional function to create With.Auth for Templates BaseContext context.Context // Non-nil base context for Requests, set to context.Background() in New() - bcastCh chan Message + bcastCh chan wire.Message subCh chan subscription - unsubCh chan chan Message + unsubCh chan chan wire.Message updateTicker *time.Ticker reqPool sync.Pool serveJS *staticserve.StaticServe @@ -89,16 +91,16 @@ type Jaws struct { // Close when the instance is no longer needed to free associated resources. func New() (jw *Jaws, err error) { var serveJS, serveCSS *staticserve.StaticServe - if serveJS, err = staticserve.New("/jaws/.jaws.js", JavascriptText); err == nil { - if serveCSS, err = staticserve.New("/jaws/.jaws.css", JawsCSS); err == nil { + if serveJS, err = staticserve.New("/jaws/.jaws.js", assets.JavascriptText); err == nil { + if serveCSS, err = staticserve.New("/jaws/.jaws.css", assets.JawsCSS); err == nil { tmp := &Jaws{ CookieName: assets.DefaultCookieName, BaseContext: context.Background(), serveJS: serveJS, serveCSS: serveCSS, - bcastCh: make(chan Message, 1), + bcastCh: make(chan wire.Message, 1), subCh: make(chan subscription, 1), - unsubCh: make(chan chan Message, 1), + unsubCh: make(chan chan wire.Message, 1), updateTicker: time.NewTicker(DefaultUpdateInterval), kg: bufio.NewReader(rand.Reader), requests: make(map[uint64]*Request), @@ -146,7 +148,7 @@ func (jw *Jaws) Done() <-chan struct{} { // strings to *template.Template. func (jw *Jaws) AddTemplateLookuper(tl TemplateLookuper) (err error) { if tl != nil { - if err = newErrNotComparable(tl); err == nil { + if err = tags.NewErrNotComparable(tl); err == nil { jw.mu.Lock() if !slices.Contains(jw.tmplookers, tl) { jw.tmplookers = append(jw.tmplookers, tl) @@ -161,7 +163,7 @@ func (jw *Jaws) AddTemplateLookuper(tl TemplateLookuper) (err error) { // the list of TemplateLookupers. func (jw *Jaws) RemoveTemplateLookuper(tl TemplateLookuper) (err error) { if tl != nil { - if err = newErrNotComparable(tl); err == nil { + if err = tags.NewErrNotComparable(tl); err == nil { jw.mu.Lock() jw.tmplookers = slices.DeleteFunc(jw.tmplookers, func(x TemplateLookuper) bool { return x == tl }) jw.mu.Unlock() @@ -342,7 +344,7 @@ func getCookieSessionsIds(h http.Header, wanted string) (cookies []uint64) { if len(val) > 1 && val[0] == '"' && val[len(val)-1] == '"' { val = val[1 : len(val)-1] } - if sessId := JawsKeyValue(val); sessId != 0 { + if sessId := assets.JawsKeyValue(val); sessId != 0 { cookies = append(cookies, sessId) } } @@ -465,7 +467,7 @@ func (jw *Jaws) GenerateHeadHTML(extra ...string) (err error) { err = errors.Join(err, e) } } - headPrefix, faviconURL := PreloadHTML(urls...) + headPrefix, faviconURL := assets.PreloadHTML(urls...) headPrefix += ` 0 { - mustBroadcast(Message{What: what.Update}) + mustBroadcast(wire.Message{What: what.Update}) } case <-t.C: jw.maintenance(requestTimeout) @@ -676,8 +678,8 @@ func (jw *Jaws) Serve() { jw.ServeWithTimeout(time.Second * 10) } -func (jw *Jaws) subscribe(rq *Request, size int) chan Message { - msgCh := make(chan Message, size) +func (jw *Jaws) subscribe(rq *Request, size int) chan wire.Message { + msgCh := make(chan wire.Message, size) select { case <-jw.Done(): close(msgCh) @@ -687,7 +689,7 @@ func (jw *Jaws) subscribe(rq *Request, size int) chan Message { return msgCh } -func (jw *Jaws) unsubscribe(msgCh chan Message) { +func (jw *Jaws) unsubscribe(msgCh chan wire.Message) { select { case <-jw.Done(): case jw.unsubCh <- msgCh: @@ -739,7 +741,7 @@ func maybePanic(err error) { // SetInner sends a request to replace the inner HTML of // all HTML elements matching target. func (jw *Jaws) SetInner(target any, innerHTML template.HTML) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.Inner, Data: string(innerHTML), @@ -749,7 +751,7 @@ func (jw *Jaws) SetInner(target any, innerHTML template.HTML) { // SetAttr sends a request to replace the given attribute value in // all HTML elements matching target. func (jw *Jaws) SetAttr(target any, attr, val string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.SAttr, Data: attr + "\n" + val, @@ -759,7 +761,7 @@ func (jw *Jaws) SetAttr(target any, attr, val string) { // RemoveAttr sends a request to remove the given attribute from // all HTML elements matching target. func (jw *Jaws) RemoveAttr(target any, attr string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.RAttr, Data: attr, @@ -769,7 +771,7 @@ func (jw *Jaws) RemoveAttr(target any, attr string) { // SetClass sends a request to set the given class in // all HTML elements matching target. func (jw *Jaws) SetClass(target any, cls string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.SClass, Data: cls, @@ -779,7 +781,7 @@ func (jw *Jaws) SetClass(target any, cls string) { // RemoveClass sends a request to remove the given class from // all HTML elements matching target. func (jw *Jaws) RemoveClass(target any, cls string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.RClass, Data: cls, @@ -789,7 +791,7 @@ func (jw *Jaws) RemoveClass(target any, cls string) { // SetValue sends a request to set the HTML "value" attribute of // all HTML elements matching target. func (jw *Jaws) SetValue(target any, val string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.Value, Data: val, @@ -801,7 +803,7 @@ func (jw *Jaws) SetValue(target any, val string) { // // The position parameter 'where' may be either a HTML ID, an child index or the text 'null'. func (jw *Jaws) Insert(target any, where, html string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.Insert, Data: where + "\n" + html, @@ -810,7 +812,7 @@ func (jw *Jaws) Insert(target any, where, html string) { // Replace replaces HTML on all HTML elements matching target. func (jw *Jaws) Replace(target any, html string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.Replace, Data: html, @@ -819,7 +821,7 @@ func (jw *Jaws) Replace(target any, html string) { // Delete removes the HTML element(s) matching target. func (jw *Jaws) Delete(target any) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.Delete, }) @@ -827,7 +829,7 @@ func (jw *Jaws) Delete(target any) { // Append calls the Javascript 'appendChild()' method on all HTML elements matching target. func (jw *Jaws) Append(target any, html template.HTML) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: target, What: what.Append, Data: string(html), @@ -850,7 +852,7 @@ var whitespaceRemover = strings.NewReplacer(" ", "", "\n", "", "\t", "") // JsCall calls the Javascript function 'jsfunc' with the argument 'jsonstr' // on all Requests that have the target UI tag. func (jw *Jaws) JsCall(tag any, jsfunc, jsonstr string) { - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: tag, What: what.Call, Data: whitespaceRemover.Replace(jsfunc) + "=" + maybeCompactJSON(jsonstr), diff --git a/core/jaws.js b/core/jaws.js deleted file mode 100644 index f23256bd..00000000 --- a/core/jaws.js +++ /dev/null @@ -1,479 +0,0 @@ -// https://github.com/linkdata/jaws -// -// This script trusts the server to HTML escape user -// provided data before sending it. The script must not -// itself HTML-escape strings from the server, as the -// server needs to be able to inject arbitrary HTML. -// -// The script needs 'jawsKey' to be defined in a HTML -// meta tag. This is a per-request randomly generated -// key used to associate the WebSocket callback with -// the initial HTTP request. - -var jaws = null; -var jawsIdPrefix = 'Jid.'; - -function jawsContains(a, v) { - return a.indexOf(String(v).trim().toLowerCase()) !== -1; -} - -function jawsIsCheckable(v) { - return jawsContains(['checkbox', 'radio'], v); -} - -function jawsHasSelection(v) { - return jawsContains(['text', 'search', 'url', 'tel', 'password'], v); -} - -function jawsIsInputTag(v) { - return jawsContains(['input', 'select', 'textarea'], v); -} - -function jawsIsTrue(v) { - return jawsContains(['true', 't', 'on', '1', 'yes', 'y', 'selected'], v); -} - -function jawsGetName(e) { - var elem = e; - while (elem != null) { - var name = elem.getAttribute('name'); - if (name == null && elem.tagName.toLowerCase() === 'button') { - name = elem.textContent; - } - if (name != null) { - return name.replaceAll('\t', ' '); - } - elem = elem.parentElement; - } - return e.id; -} - -function jawsClickHandler(e) { - if (jaws instanceof WebSocket && e instanceof Event) { - e.stopPropagation(); - var elem = e.target; - var val = jawsGetName(elem); - while (elem != null) { - if (elem.id.startsWith(jawsIdPrefix) && !jawsIsInputTag(elem.tagName)) { - val += "\t" + elem.id; - } - elem = elem.parentElement; - } - jaws.send("Click\t\t" + JSON.stringify(val) + "\n"); - } -} - -function jawsInputHandler(e) { - if (jaws instanceof WebSocket && e instanceof Event) { - e.stopPropagation(); - var val; - var elem = e.currentTarget; - if (jawsIsCheckable(elem.getAttribute('type'))) { - val = elem.checked; - } else if (elem.tagName.toLowerCase() === 'option') { - val = elem.selected; - } else { - val = elem.value; - } - jaws.send("Input\t" + elem.id + "\t" + JSON.stringify(val) + "\n"); - } -} - -function jawsRemoving(topElem) { - var elements = topElem.querySelectorAll('[id^="' + jawsIdPrefix + '"]'); - if (elements.length == 0) return; - var val = ''; - for (var i = 0; i < elements.length; i++) { - if (i > 0) { - val += '\t'; - } - val += elements[i].id; - } - jaws.send("Remove\t" + topElem.id + "\t" + JSON.stringify(val) + "\n"); -} - -function jawsAttach(elem) { - if (elem.hasAttribute("data-jawsname")) { - var name = elem.dataset.jawsname; - window.jawsNames[name] = elem.id; - if (elem.hasAttribute("data-jawsdata")) { - jawsVar(name, JSON.parse(elem.dataset.jawsdata), 'Set'); - } - return; - } - if (jawsIsInputTag(elem.tagName)) { - elem.addEventListener('input', jawsInputHandler, false); - return; - } - elem.addEventListener('click', jawsClickHandler, false); -} - -function jawsAttachChildren(topElem) { - topElem.querySelectorAll('[data-jawsonchangesubmit]').forEach(function(elem) { - elem.addEventListener('change', function() { this.form.submit(); }); - }); - topElem.querySelectorAll('[id^="' + jawsIdPrefix + '"]').forEach(jawsAttach); - return topElem; -} - -function jawsAlert(data) { - var lines = data.split('\n'); - var type = lines.shift(); - var message = lines.join('\n'); - if (typeof bootstrap !== 'undefined') { - var alertsElem = document.getElementById('jaws-alerts'); - if (alertsElem) { - var wrapper = document.createElement('div'); - wrapper.innerHTML = ''; - alertsElem.append(wrapper); - return; - } - } - console.log("jaws: " + type + ": " + message); -} - -function jawsList(idlist) { - var i; - var elements = []; - var idstrings = idlist.split(' '); - for (i = 0; i < idstrings.length; i++) { - var elem = document.getElementById(idstrings[i]); - if (elem) { - elem.dataset.jidsort = i; - elements.push(elem); - } - } - elements.sort(function (a, b) { - return +a.dataset.jidsort - +b.dataset.jidsort; - }); - for (i = 0; i < elements.length; i++) { - delete elements[i].dataset.jidsort; - } - return elements; -} - -function jawsOrder(idlist) { - var i; - var elements = jawsList(idlist); - for (i = 0; i < elements.length; i++) { - elements[i].parentElement.appendChild(elements[i]); - } -} - -function jawsSetValue(elem, str) { - var elemtype = elem.getAttribute('type'); - if (jawsIsCheckable(elemtype)) { - elem.checked = jawsIsTrue(str); - return; - } - if (elem.tagName.toLowerCase() === 'option') { - elem.selected = jawsIsTrue(str); - return; - } - if (elem.tagName.toLowerCase() === 'textarea') { - if (elem.textContent != str) { - elem.textContent = str; - // work around browser bug where textContent - // and textLength go out of sync sometimes - if (elem.textLength != elem.textContent.length) { - var parent = elem.parentNode; - var clone = document.createElement(elem.tagName); - for (var i = 0; i < elem.attributes.length; i++) { - clone.setAttribute(elem.attributes[i].nodeName, elem.attributes[i].nodeValue); - } - clone.textContent = str; - parent.insertBefore(clone, elem); - elem.remove(); - jawsAttach(clone); - } - } - return; - } - if (elem.value == str) { - return; - } - if (jawsHasSelection(elemtype)) { - var ss = elem.selectionStart; - var se = elem.selectionEnd; - var oldVal = elem.value; - var delta = str.indexOf(oldVal); - elem.value = str; - if (delta == -1) { - delta = oldVal.indexOf(str); - if (delta == -1) return; - delta = -delta; - } - elem.selectionStart = ss + delta; - elem.selectionEnd = se + delta; - return; - } - elem.value = str; -} - -function jawsLost() { - var delay = 1; - var innerHTML = 'Server connection lost'; - if (jaws instanceof Date) { - var elapsed = Math.floor((new Date() - jaws) / 1000); - if (elapsed > 0) { - var units = ' second'; - delay = elapsed; - if (elapsed >= 60) { - delay = 60; - units = ' minute'; - elapsed = Math.floor(elapsed / 60); - if (elapsed >= 60) { - units = ' hour'; - elapsed = Math.floor(elapsed / 60); - } - } - if (elapsed > 1) units += 's'; - innerHTML += ' ' + elapsed + units + ' ago'; - } - } - innerHTML += '. Trying to reconnect.'; - var elem = document.getElementById('jaws-lost'); - if (elem == null) { - elem = jawsElement('
' + innerHTML + '
'); - document.body.prepend(elem); - document.body.scrollTop = document.documentElement.scrollTop = 0; - } else { - elem.innerHTML = innerHTML; - } - setTimeout(jawsReconnect, delay * 1000); -} - -function jawsHandleReconnect(e) { - if (e.currentTarget.readyState == 4) { - if (e.currentTarget.status == 204) { - window.location.reload(); - } else { - jawsLost(); - } - } -} - -function jawsReconnect() { - var req = new XMLHttpRequest(); - req.open("GET", window.location.protocol + "//" + window.location.host + "/jaws/.ping", true); - req.addEventListener('readystatechange', jawsHandleReconnect); - req.send(null); -} - -function jawsFailed(e) { - if (jaws instanceof WebSocket) { - jaws = new Date(); - jawsReconnect(); - } -} - -function jawsUnloading() { - if (jaws instanceof WebSocket) { - jaws.removeEventListener('close', jawsFailed); - jaws.removeEventListener('error', jawsFailed); - jaws.close(); - jaws = null; - } -} - -function jawsElement(html) { - var template = document.createElement('template'); - template.innerHTML = html; - return template.content; -} - -function jawsWhere(elem, pos) { - var where = null; - if (pos && pos !== 'null') { - where = document.getElementById(pos); - if (where == null) { - where = elem.children[parseInt(pos)]; - } - } - if (!(where instanceof Node)) { - console.log("jaws: id " + elem.id + " has no position " + pos); - } - return where; -} - -function jawsInsert(elem, data) { - var idx = data.indexOf('\n'); - var where = jawsWhere(elem, data.substring(0, idx)); - if (where instanceof Node) { - elem.insertBefore(jawsAttachChildren(jawsElement(data.substring(idx + 1))), where); - } -} - -function jawsSetAttr(elem, data) { - var idx = data.indexOf('\n'); - elem.setAttribute(data.substring(0, idx), data.substring(idx + 1)); -} - -function jawsMessage(e) { - var orders = e.data.split('\n'); - var i; - for (i = 0; i < orders.length; i++) { - if (orders[i]) { - var parts = orders[i].split('\t'); - jawsPerform(parts.shift(), parts.shift(), parts.shift()); - } - } -} - -function jawsVar(name, data, operation) { - var keys = name.split('.').filter(function(key){return key != "";}); - if (keys.length > 0) { - var obj = window; - var lastkey = keys[keys.length - 1]; - var i; - var path = keys.slice(1).join("."); - name = keys[0]; - for (i = 0; i < keys.length - 1; i++) { - if (!obj.hasOwnProperty(keys[i])) { - throw "jaws: object undefined: " + name; - } - obj = obj[keys[i]]; - } - switch (operation) { - case undefined: - if (data === undefined) { - data = obj[lastkey]; - } else { - obj[lastkey] = data; - } - if (jaws instanceof WebSocket && jaws.readyState === 1) { - var id = window.jawsNames[name]; - if (typeof id === 'string' && id.startsWith(jawsIdPrefix)) { - jaws.send("Set\t" + id + "\t" + path + "=" + JSON.stringify(data) + "\n"); - } - } - return data; - case 'Call': - if (typeof obj[lastkey] === 'function') { - obj[lastkey](data); - return; - } - throw "jaws: not a function: " + name + path; - case 'Set': - if (typeof obj[lastkey] !== 'function') { - obj[lastkey] = data; - } - return data; - default: - throw "jaws: unknown operation: " + operation; - } - } -} - -function jawsPerform(what, id, data) { - var path = ""; - if (what == 'Set' || what == 'Call') { - var equalPos = data.indexOf("="); - if (equalPos > 0) { - path = data.slice(0, equalPos); - } - data = data.slice(equalPos + 1); - } - data = JSON.parse(data); - switch (what) { - case 'Reload': - window.location.reload(); - return; - case 'Redirect': - window.location.assign(data); - return; - case 'Alert': - jawsAlert(data); - return; - case 'Order': - jawsOrder(data); - return; - } - var elem = document.getElementById(id); - if (elem === null) { - throw "jaws: element not found: " + id; - } - var where = null; - switch (what) { - case 'Inner': - jawsRemoving(elem); - elem.innerHTML = data; - jawsAttachChildren(elem); - return; - case 'Value': - jawsSetValue(elem, data); - return; - case 'Append': - elem.appendChild(jawsAttachChildren(jawsElement(data))); - return; - case 'Replace': - jawsRemoving(elem); - elem.replaceWith(jawsAttachChildren(jawsElement(data))); - return; - case 'Delete': - jawsRemoving(elem); - elem.remove(); - return; - case 'Remove': - where = jawsWhere(elem, data); - if (where instanceof Node) { - jawsRemoving(where); - elem.removeChild(where); - } - return; - case 'Insert': - jawsInsert(elem, data); - return; - case 'SAttr': - jawsSetAttr(elem, data); - return; - case 'RAttr': - elem.removeAttribute(data); - return; - case 'SClass': - elem.classList.add(data); - return; - case 'RClass': - elem.classList.remove(data); - return; - case 'Call': - jawsVar(path, data, what); - return; - case 'Set': - if (elem.dataset.jawsname) { - jawsVar(elem.dataset.jawsname + "." + path, data, what); - } else { - console.log("jaws: id " + id + " is not a JsVar"); - } - return; - } - throw "jaws: unknown operation: " + what; -} - -function jawsPageshow(e) { - if (e.persisted) { - window.location.reload(); - } -} - -function jawsConnect() { - var wsScheme = 'ws://'; - if (window.location.protocol === 'https:') { - wsScheme = 'wss://'; - } - window.addEventListener('beforeunload', jawsUnloading); - window.addEventListener('pageshow', jawsPageshow); - jaws = new WebSocket(wsScheme + window.location.host + '/jaws/' + encodeURIComponent(document.querySelector('meta[name="jawsKey"]').content)); - jaws.addEventListener('message', jawsMessage); - jaws.addEventListener('close', jawsFailed); - jaws.addEventListener('error', jawsFailed); -} - -window.jawsNames = {}; -jawsAttachChildren(document); -if (document.readyState === 'complete' || document.readyState === 'interactive') { - jawsConnect(); -} else { - window.addEventListener('DOMContentLoaded', jawsConnect); -} diff --git a/core/js.go b/core/js.go deleted file mode 100644 index f319c97b..00000000 --- a/core/js.go +++ /dev/null @@ -1,33 +0,0 @@ -package jaws - -import ( - "net/url" - - "github.com/linkdata/jaws/core/assets" -) - -// JavascriptText is the source code for the client-side JaWS Javascript library. -var JavascriptText = assets.JavascriptText - -// JawsCSS is the built-in JaWS stylesheet. -var JawsCSS = assets.JawsCSS - -// JawsKeyAppend appends the JaWS key as a string to the buffer. -func JawsKeyAppend(b []byte, jawsKey uint64) []byte { - return assets.JawsKeyAppend(b, jawsKey) -} - -// JawsKeyString returns the string to be used for the given JaWS key. -func JawsKeyString(jawsKey uint64) string { - return assets.JawsKeyString(jawsKey) -} - -// JawsKeyValue parses a key string (as returned JawsKeyString) into a uint64. -func JawsKeyValue(jawsKey string) uint64 { - return assets.JawsKeyValue(jawsKey) -} - -// PreloadHTML returns HTML code to load the given resources efficiently. -func PreloadHTML(urls ...*url.URL) (htmlcode, faviconurl string) { - return assets.PreloadHTML(urls...) -} diff --git a/core/message.go b/core/message.go deleted file mode 100644 index aa76e689..00000000 --- a/core/message.go +++ /dev/null @@ -1,6 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/wire" - -// Message contains the elements of a message to be sent to Requests. -type Message = wire.Message diff --git a/core/parseparams.go b/core/parseparams.go index 267435b2..d7fc419e 100644 --- a/core/parseparams.go +++ b/core/parseparams.go @@ -1,11 +1,15 @@ package jaws -import "html/template" +import ( + "html/template" + + "github.com/linkdata/jaws/core/tags" +) // shouldAutoTagHandler returns true if a handler value is safe to use as a tag. -// Non-comparable handlers are only auto-tagged when they provide an explicit TagGetter. +// Non-comparable handlers are only auto-tagged when they provide an explicit tags.TagGetter. func shouldAutoTagHandler(handler any) any { - if _, ok := handler.(TagGetter); ok || newErrNotComparable(handler) == nil { + if _, ok := handler.(tags.TagGetter); ok || tags.NewErrNotComparable(handler) == nil { return handler } return nil diff --git a/core/request.go b/core/request.go index 38bdb9e9..41350a6f 100644 --- a/core/request.go +++ b/core/request.go @@ -19,6 +19,9 @@ import ( "time" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/assets" + "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/what" ) @@ -51,7 +54,7 @@ type Request struct { elems []*Element // our Elements tagMap map[any][]*Element // maps tags to Elements muQueue deadlock.Mutex // protects wsQueue and tailsent - wsQueue []WsMsg // queued messages to send + wsQueue []wire.WsMsg // queued messages to send tailsent bool } @@ -74,7 +77,7 @@ func (rq *Request) JawsKeyString() string { if rq != nil { jawsKey = rq.JawsKey } - return JawsKeyString(jawsKey) + return assets.JawsKeyString(jawsKey) } func (rq *Request) String() string { @@ -159,7 +162,7 @@ func (rq *Request) HeadHTML(w io.Writer) (err error) { rq.Jaws.mu.RLock() b = append(b, rq.Jaws.headPrefix...) rq.Jaws.mu.RUnlock() - b = JawsKeyAppend(b, rq.JawsKey) + b = assets.JawsKeyAppend(b, rq.JawsKey) b = append(b, `">`...) _, err = w.Write(b) return @@ -211,7 +214,7 @@ func (rq *Request) writeTailScript(w io.Writer) (sent bool, err error) { } } for i := n; i < len(rq.wsQueue); i++ { - rq.wsQueue[i] = WsMsg{} + rq.wsQueue[i] = wire.WsMsg{} } rq.wsQueue = rq.wsQueue[:n] if len(b) > 0 { @@ -354,7 +357,7 @@ func (rq *Request) cancel(err error) { // The default JaWS javascript only supports Bootstrap.js dismissable alerts. // See Jaws.Broadcast for processing-loop requirements. func (rq *Request) Alert(lvl, msg string) { - rq.Jaws.Broadcast(Message{ + rq.Jaws.Broadcast(wire.Message{ Dest: rq, What: what.Alert, Data: lvl + "\n" + msg, @@ -371,7 +374,7 @@ func (rq *Request) AlertError(err error) { // Redirect requests the current Request to navigate to the given URL. // See Jaws.Broadcast for processing-loop requirements. func (rq *Request) Redirect(url string) { - rq.Jaws.Broadcast(Message{ + rq.Jaws.Broadcast(wire.Message{ Dest: rq, What: what.Redirect, Data: url, @@ -400,12 +403,12 @@ func (rq *Request) TagsOf(elem *Element) (tags []any) { } // Dirty marks all Elements that have one or more of the given tags as dirty. -func (rq *Request) Dirty(tags ...any) { - rq.Jaws.setDirty(MustTagExpand(rq, tags)) +func (rq *Request) Dirty(dirtyTags ...any) { + rq.Jaws.setDirty(tags.MustTagExpand(rq, dirtyTags)) } // wantMessage returns true if the Request want the message. -func (rq *Request) wantMessage(msg *Message) (yes bool) { +func (rq *Request) wantMessage(msg *wire.Message) (yes bool) { switch dest := msg.Dest.(type) { case *Request: return dest == rq @@ -445,7 +448,7 @@ func (rq *Request) newElementLocked(ui UI) (elem *Element) { // Panics if the build tag "debug" is set and the UI object doesn't satisfy all requirements. func (rq *Request) NewElement(ui UI) *Element { if deadlock.Debug { - if err := newErrNotComparable(ui); err != nil { + if err := tags.NewErrNotComparable(ui); err != nil { panic(err) } } @@ -507,19 +510,19 @@ func (rq *Request) TagExpanded(elem *Element, expandedtags []any) { } // Tag adds the given tags to the given Element. -func (rq *Request) Tag(elem *Element, tags ...any) { - if elem != nil && len(tags) > 0 && elem.Request == rq { - rq.TagExpanded(elem, MustTagExpand(elem.Request, tags)) +func (rq *Request) Tag(elem *Element, tagItems ...any) { + if elem != nil && len(tagItems) > 0 && elem.Request == rq { + rq.TagExpanded(elem, tags.MustTagExpand(elem.Request, tagItems)) } } // GetElements returns a list of the UI elements in the Request that have the given tag(s). func (rq *Request) GetElements(tagitem any) (elems []*Element) { - tags := MustTagExpand(rq, tagitem) + expanded := tags.MustTagExpand(rq, tagitem) seen := map[*Element]struct{}{} rq.mu.RLock() defer rq.mu.RUnlock() - for _, tag := range tags { + for _, tag := range expanded { if el, ok := rq.tagMap[tag]; ok { for _, e := range el { if _, ok = seen[e]; !ok { @@ -533,7 +536,7 @@ func (rq *Request) GetElements(tagitem any) (elems []*Element) { } // process is the main message processing loop. Will unsubscribe broadcastMsgCh and close outboundMsgCh on exit. -func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsMsg, outboundMsgCh chan<- WsMsg) { +func (rq *Request) process(broadcastMsgCh chan wire.Message, incomingMsgCh <-chan wire.WsMsg, outboundMsgCh chan<- wire.WsMsg) { jawsDoneCh := rq.Jaws.Done() httpDoneCh := rq.httpDoneCh eventDoneCh := make(chan struct{}) @@ -567,8 +570,8 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsM }() for { - var tagmsg Message - var wsmsg WsMsg + var tagmsg wire.Message + var wsmsg wire.WsMsg var ok bool rq.sendQueue(outboundMsgCh) @@ -619,7 +622,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsM if tagmsg.What != what.Set && tagmsg.What != what.Call { data = strconv.Quote(data) } - rq.queue(WsMsg{ + rq.queue(wire.WsMsg{ Data: v + "\t" + data, What: tagmsg.What, Jid: -1, @@ -630,7 +633,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsM switch tagmsg.What { case what.Reload, what.Redirect, what.Order, what.Alert: - rq.queue(WsMsg{ + rq.queue(wire.WsMsg{ Jid: 0, Data: tagmsg.Data, What: tagmsg.What, @@ -639,7 +642,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsM for _, elem := range todo { switch tagmsg.What { case what.Delete: - rq.queue(WsMsg{ + rq.queue(wire.WsMsg{ Jid: elem.Jid(), What: what.Delete, }) @@ -656,7 +659,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsM // an error to be sent out as an alert message. // primary usecase is tests. if err := rq.Jaws.Log(rq.callAllEventHandlers(elem.Jid(), tagmsg.What, tagmsg.Data)); err != nil { - var m WsMsg + var m wire.WsMsg m.FillAlert(err) m.Jid = elem.Jid() rq.queue(m) @@ -664,7 +667,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsM case what.Update: elem.JawsUpdate() default: - rq.queue(WsMsg{ + rq.queue(wire.WsMsg{ Data: tagmsg.Data, Jid: elem.Jid(), What: tagmsg.What, @@ -690,7 +693,7 @@ func (rq *Request) handleRemove(containerJid Jid, data string) { } } -func (rq *Request) queue(msg WsMsg) { +func (rq *Request) queue(msg wire.WsMsg) { rq.muQueue.Lock() rq.wsQueue = append(rq.wsQueue, msg) rq.muQueue.Unlock() @@ -741,7 +744,7 @@ func (rq *Request) queueEvent(eventCallCh chan eventFnCall, call eventFnCall) { } } -func (rq *Request) getSendMsgs() (toSend []WsMsg) { +func (rq *Request) getSendMsgs() (toSend []wire.WsMsg) { rq.mu.RLock() defer rq.mu.RUnlock() @@ -767,11 +770,11 @@ func (rq *Request) getSendMsgs() (toSend []WsMsg) { rq.wsQueue = rq.wsQueue[:0] } - slices.SortStableFunc(toSend, func(a, b WsMsg) int { return cmp.Compare(a.Jid, b.Jid) }) + slices.SortStableFunc(toSend, func(a, b wire.WsMsg) int { return cmp.Compare(a.Jid, b.Jid) }) return } -func (rq *Request) sendQueue(outboundMsgCh chan<- WsMsg) { +func (rq *Request) sendQueue(outboundMsgCh chan<- wire.WsMsg) { for _, msg := range rq.getSendMsgs() { select { case <-rq.Context().Done(): @@ -843,7 +846,7 @@ func (rq *Request) makeUpdateList() (todo []*Element) { } // eventCaller calls event functions -func (rq *Request) eventCaller(eventCallCh <-chan eventFnCall, outboundMsgCh chan<- WsMsg, eventDoneCh chan<- struct{}) { +func (rq *Request) eventCaller(eventCallCh <-chan eventFnCall, outboundMsgCh chan<- wire.WsMsg, eventDoneCh chan<- struct{}) { defer close(eventDoneCh) for call := range eventCallCh { select { @@ -852,7 +855,7 @@ func (rq *Request) eventCaller(eventCallCh <-chan eventFnCall, outboundMsgCh cha default: } if err := rq.callAllEventHandlers(call.jid, call.wht, call.data); err != nil { - var m WsMsg + var m wire.WsMsg m.FillAlert(err) select { case outboundMsgCh <- m: diff --git a/core/servehttp.go b/core/servehttp.go index 13ecc38f..b9a397fb 100644 --- a/core/servehttp.go +++ b/core/servehttp.go @@ -3,6 +3,8 @@ package jaws import ( "net/http" "strings" + + "github.com/linkdata/jaws/core/assets" ) var headerCacheControlNoStore = []string{"no-store"} @@ -33,7 +35,7 @@ func (jw *Jaws) ServeHTTP(w http.ResponseWriter, r *http.Request) { return default: if jawsKeyString, ok := strings.CutPrefix(r.URL.Path, "/jaws/.tail/"); ok { - jawsKey := JawsKeyValue(jawsKeyString) + jawsKey := assets.JawsKeyValue(jawsKeyString) jw.mu.RLock() rq := jw.requests[jawsKey] jw.mu.RUnlock() @@ -45,7 +47,7 @@ func (jw *Jaws) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } } - } else if rq := jw.UseRequest(JawsKeyValue(r.URL.Path[6:]), r); rq != nil { + } else if rq := jw.UseRequest(assets.JawsKeyValue(r.URL.Path[6:]), r); rq != nil { rq.ServeHTTP(w, r) return } diff --git a/core/session.go b/core/session.go index aa989b0e..227e5c97 100644 --- a/core/session.go +++ b/core/session.go @@ -6,6 +6,8 @@ import ( "time" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/assets" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/what" ) @@ -29,7 +31,7 @@ func newSession(jw *Jaws, sessionID uint64, remoteIP netip.Addr, secure bool) *S cookie: http.Cookie{ Name: jw.CookieName, Path: "/", - Value: JawsKeyString(sessionID), + Value: assets.JawsKeyString(sessionID), Secure: secure, HttpOnly: true, SameSite: http.SameSiteLaxMode, @@ -174,7 +176,7 @@ func (sess *Session) Close() (cookie *http.Cookie) { *cookie = sess.cookie sess.mu.Unlock() - msg := Message{What: what.Reload} + msg := wire.Message{What: what.Reload} for _, rq := range requests { rq.deadSession(sess) msg.Dest = rq @@ -187,7 +189,7 @@ func (sess *Session) Close() (cookie *http.Cookie) { // Reload calls Broadcast with a message asking browsers to reload the page. // See Broadcast for the processing-loop requirement. func (sess *Session) Reload() { - sess.Broadcast(Message{What: what.Reload}) + sess.Broadcast(wire.Message{What: what.Reload}) } // Clear removes all key/value pairs from the session. @@ -216,7 +218,7 @@ func (sess *Session) Requests() (rl []*Request) { // It must not be called before the JaWS processing loop (`Serve()` or // `ServeWithTimeout()`) is running. Otherwise this call may block. // It is safe to call on a nil Session. -func (sess *Session) Broadcast(msg Message) { +func (sess *Session) Broadcast(msg wire.Message) { if sess != nil { sess.mu.RLock() defer sess.mu.RUnlock() diff --git a/core/subscription.go b/core/subscription.go index 6cfcef67..27b12a2f 100644 --- a/core/subscription.go +++ b/core/subscription.go @@ -1,6 +1,8 @@ package jaws +import "github.com/linkdata/jaws/core/wire" + type subscription struct { - msgCh chan Message + msgCh chan wire.Message rq *Request } diff --git a/core/tag.go b/core/tag.go deleted file mode 100644 index 6ef2fc2e..00000000 --- a/core/tag.go +++ /dev/null @@ -1,19 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/tags" - -type Tag = tags.Tag - -var ErrTooManyTags = tags.ErrTooManyTags - -func TagString(tag any) string { - return tags.TagString(tag) -} - -func TagExpand(rq *Request, tag any) ([]any, error) { - return tags.TagExpand(rq, tag) -} - -func MustTagExpand(rq *Request, tag any) []any { - return tags.MustTagExpand(rq, tag) -} diff --git a/core/tag_testsupport_test.go b/core/tag_testsupport_test.go new file mode 100644 index 00000000..26fd6328 --- /dev/null +++ b/core/tag_testsupport_test.go @@ -0,0 +1,28 @@ +package jaws + +import ( + "github.com/linkdata/jaws/core/assets" + "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" +) + +type TagContext = tags.Context +type TagGetter = tags.TagGetter +type Tag = tags.Tag +type Message = wire.Message +type WsMsg = wire.WsMsg + +var MustTagExpand = tags.MustTagExpand +var TagExpand = tags.TagExpand +var TagString = tags.TagString +var JawsKeyAppend = assets.JawsKeyAppend +var JawsKeyString = assets.JawsKeyString +var JawsKeyValue = assets.JawsKeyValue +var JavascriptText = assets.JavascriptText +var JawsCSS = assets.JawsCSS + +func (tt *testSelfTagger) JawsGetTag(TagContext) any { + return tt +} + +type testSelfTagger struct{} diff --git a/core/taggetter.go b/core/taggetter.go deleted file mode 100644 index 929c1700..00000000 --- a/core/taggetter.go +++ /dev/null @@ -1,8 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/tags" - -// TagContext is passed to TagGetter.JawsGetTag when resolving dynamic tags. -type TagContext = tags.Context - -type TagGetter = tags.TagGetter diff --git a/core/errnotcomparable_test.go b/core/tags/errnotcomparable_test.go similarity index 83% rename from core/errnotcomparable_test.go rename to core/tags/errnotcomparable_test.go index 988fd67b..294a0c6f 100644 --- a/core/errnotcomparable_test.go +++ b/core/tags/errnotcomparable_test.go @@ -1,4 +1,4 @@ -package jaws +package tags import ( "errors" @@ -10,7 +10,7 @@ type testRuntimeNonComparable struct { } func Test_newErrNotComparable_Error(t *testing.T) { - err := newErrNotComparable([]int{1, 2, 3}) + err := NewErrNotComparable([]int{1, 2, 3}) if err == nil { t.Fatal("expected error for non-comparable value") } @@ -26,7 +26,7 @@ func Test_newErrNotComparable_Error(t *testing.T) { } func Test_newErrNotComparable_RuntimeNonComparable(t *testing.T) { - err := newErrNotComparable(testRuntimeNonComparable{v: func() {}}) + err := NewErrNotComparable(testRuntimeNonComparable{v: func() {}}) if !errors.Is(err, ErrNotComparable) { t.Fatalf("expected ErrNotComparable, got %v", err) } diff --git a/core/errnotusableastag_test.go b/core/tags/errnotusableastag_test.go similarity index 84% rename from core/errnotusableastag_test.go rename to core/tags/errnotusableastag_test.go index ddd97b9e..6ccd913e 100644 --- a/core/errnotusableastag_test.go +++ b/core/tags/errnotusableastag_test.go @@ -1,15 +1,13 @@ -package jaws +package tags import ( "reflect" "testing" - - "github.com/linkdata/jaws/core/tags" ) type testFindTagGetter struct{} -func (testFindTagGetter) JawsGetTag(tags.Context) any { +func (testFindTagGetter) JawsGetTag(Context) any { return Tag("tg") } @@ -22,7 +20,7 @@ type testFindTagGetterDepth struct { } func TestFindTagGetter_NoMatchCases(t *testing.T) { - if path, tgType, found := findTagGetter(nil); found || path != "" || tgType != nil { + if path, tgType, found := FindTagGetter(nil); found || path != "" || tgType != nil { t.Fatalf("expected no match for nil input, got found=%v path=%q type=%v", found, path, tgType) } @@ -47,7 +45,7 @@ func TestFindTagGetter_NoMatchCases(t *testing.T) { SB: shared, // same backing array triggers seen-slice path } - if path, tgType, found := findTagGetter(noMatch); found || path != "" || tgType != nil { + if path, tgType, found := FindTagGetter(noMatch); found || path != "" || tgType != nil { t.Fatalf("expected no match, got found=%v path=%q type=%v", found, path, tgType) } } @@ -59,7 +57,7 @@ func TestFindTagGetter_DepthLimit(t *testing.T) { curr.Next = &testFindTagGetterDepth{} curr = curr.Next } - if path, tgType, found := findTagGetter(root); found || path != "" || tgType != nil { + if path, tgType, found := FindTagGetter(root); found || path != "" || tgType != nil { t.Fatalf("expected no match when depth limit is hit, got found=%v path=%q type=%v", found, path, tgType) } } @@ -71,7 +69,7 @@ func TestFindTagGetter_ArrayAndSliceMatches(t *testing.T) { } }{} withArray.Outer.Arr[3] = testFindTagGetter{} - path, tgType, found := findTagGetter(withArray) + path, tgType, found := FindTagGetter(withArray) if !found { t.Fatal("expected array TagGetter match") } @@ -89,7 +87,7 @@ func TestFindTagGetter_ArrayAndSliceMatches(t *testing.T) { }{} withSlice.Outer.S = make([]any, 5) withSlice.Outer.S[2] = testFindTagGetter{} - path, tgType, found = findTagGetter(withSlice) + path, tgType, found = FindTagGetter(withSlice) if !found { t.Fatal("expected slice TagGetter match") } diff --git a/core/tag_test.go b/core/tags/tag_test.go similarity index 93% rename from core/tag_test.go rename to core/tags/tag_test.go index 11f30b2b..532da9bd 100644 --- a/core/tag_test.go +++ b/core/tags/tag_test.go @@ -1,4 +1,4 @@ -package jaws +package tags import ( "errors" @@ -10,19 +10,17 @@ import ( "testing" "github.com/linkdata/deadlock" - "github.com/linkdata/jaws/core/tags" ) -type testSelfTagger struct { -} +type testSelfTagger struct{} -func (tt *testSelfTagger) JawsGetTag(tags.Context) any { +func (tt *testSelfTagger) JawsGetTag(Context) any { return tt } type testBadTagGetter []int -func (tt testBadTagGetter) JawsGetTag(tags.Context) any { +func (tt testBadTagGetter) JawsGetTag(Context) any { return tt } @@ -30,15 +28,22 @@ type testStringTag struct{} func (testStringTag) String() string { return "str" } +type testNestedTagGetter struct{} + +func (testNestedTagGetter) JawsGetTag(Context) any { + return Tag("nested") +} + type testTagExpandNestedTagGetter struct { - Setter Setter[int] + Setter testNestedTagGetter + Vals []int } func TestTagString_StringerAndPointer(t *testing.T) { if got := TagString(testStringTag{}); !strings.Contains(got, "testStringTag(str)") { t.Fatalf("TagString(testStringTag{}) = %q, want value stringer representation", got) } - if got := TagString(&testStringTag{}); !strings.Contains(got, "*jaws.testStringTag(") { + if got := TagString(&testStringTag{}); !strings.Contains(got, "*tags.testStringTag(") { t.Fatalf("TagString(&testStringTag{}) = %q, want pointer representation", got) } } @@ -46,6 +51,7 @@ func TestTagString_StringerAndPointer(t *testing.T) { func TestTagExpand(t *testing.T) { var av atomic.Value selftagger := &testSelfTagger{} + boom := errors.New("boom") tests := []struct { name string tag any @@ -78,8 +84,8 @@ func TestTagExpand(t *testing.T) { }, { name: "error", - tag: ErrEventUnhandled, - want: []any{ErrEventUnhandled}, + tag: boom, + want: []any{boom}, }, } for _, tt := range tests { @@ -169,10 +175,9 @@ func TestTagExpand_TagGetterNonComparable(t *testing.T) { } func TestTagExpand_NotUsableAsTag_WithNestedTagGetterHint(t *testing.T) { - var mu deadlock.Mutex - var val int tag := testTagExpandNestedTagGetter{ - Setter: Bind(&mu, &val).Success(func() {}), + Setter: testNestedTagGetter{}, + Vals: []int{1}, } _, err := TagExpand(nil, tag) if !errors.Is(err, ErrNotUsableAsTag) { diff --git a/core/testsupport.go b/core/testsupport.go index 50dd5c42..a70ab94b 100644 --- a/core/testsupport.go +++ b/core/testsupport.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "strings" + + "github.com/linkdata/jaws/core/wire" ) // TestRequest is a request harness intended for tests. @@ -16,9 +18,9 @@ type TestRequest struct { *httptest.ResponseRecorder ReadyCh chan struct{} DoneCh chan struct{} - InCh chan WsMsg - OutCh chan WsMsg - BcastCh chan Message + InCh chan wire.WsMsg + OutCh chan wire.WsMsg + BcastCh chan wire.Message ExpectPanic bool Panicked bool PanicVal any @@ -44,8 +46,8 @@ func NewTestRequest(jw *Jaws, hr *http.Request) (tr *TestRequest) { tr = &TestRequest{ ReadyCh: make(chan struct{}), DoneCh: make(chan struct{}), - InCh: make(chan WsMsg), - OutCh: make(chan WsMsg, cap(bcastCh)), + InCh: make(chan wire.WsMsg), + OutCh: make(chan wire.WsMsg, cap(bcastCh)), BcastCh: bcastCh, Request: rq, ResponseRecorder: rr, diff --git a/core/message_test.go b/core/wire/message_test.go similarity index 96% rename from core/message_test.go rename to core/wire/message_test.go index d4a52a96..8d10bbb9 100644 --- a/core/message_test.go +++ b/core/wire/message_test.go @@ -1,4 +1,4 @@ -package jaws +package wire import ( "testing" diff --git a/core/wsmsg_test.go b/core/wire/wsmsg_test.go similarity index 81% rename from core/wsmsg_test.go rename to core/wire/wsmsg_test.go index b421bb6c..250330dc 100644 --- a/core/wsmsg_test.go +++ b/core/wire/wsmsg_test.go @@ -1,4 +1,4 @@ -package jaws +package wire import ( "bytes" @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/what" ) @@ -24,7 +25,7 @@ func Benchmark_wsMsg_AppendAlert(b *testing.B) { func Test_wsMsg_Append(t *testing.T) { type fields struct { Data string - Jid Jid + Jid jid.Jid What what.What } tests := []struct { @@ -87,9 +88,9 @@ func Test_wsMsg_Append(t *testing.T) { if got := string(m.Append(nil)); !reflect.DeepEqual(got, tt.want) { t.Errorf("wsMsg.Append() = %q, want %q", got, tt.want) } else if !tt.noparse { - m2, ok := wsParse([]byte(got)) + m2, ok := Parse([]byte(got)) if !ok || !reflect.DeepEqual(m, m2) { - t.Errorf("wsParse(%q) = %v, %v want %v", got, m2, ok, m) + t.Errorf("Parse(%q) = %v, %v want %v", got, m2, ok, m) } } }) @@ -97,7 +98,7 @@ func Test_wsMsg_Append(t *testing.T) { } func Test_wsMsg_AppendNegativeJidAndCallPayload(t *testing.T) { - msg := WsMsg{Jid: Jid(-1), What: what.Update, Data: "raw\tdata"} + msg := WsMsg{Jid: jid.Jid(-1), What: what.Update, Data: "raw\tdata"} if got := string(msg.Append(nil)); got != "Update\traw\tdata\n" { t.Fatalf("unexpected ws append result %q", got) } @@ -115,15 +116,15 @@ func Test_wsParse_CompletePasses(t *testing.T) { want WsMsg }{ {"shortest", "Update\t\t\n", WsMsg{What: what.Update}}, - {"unquoted", "Input\tJid.1\ttrue\n", WsMsg{Jid: Jid(1), What: what.Input, Data: "true"}}, - {"normal", "Input\tJid.2\t\"c\"\n", WsMsg{Jid: Jid(2), What: what.Input, Data: "c"}}, - {"newline", "Input\tJid.3\t\"c\\nd\"\n", WsMsg{Jid: Jid(3), What: what.Input, Data: "c\nd"}}, + {"unquoted", "Input\tJid.1\ttrue\n", WsMsg{Jid: jid.Jid(1), What: what.Input, Data: "true"}}, + {"normal", "Input\tJid.2\t\"c\"\n", WsMsg{Jid: jid.Jid(2), What: what.Input, Data: "c"}}, + {"newline", "Input\tJid.3\t\"c\\nd\"\n", WsMsg{Jid: jid.Jid(3), What: what.Input, Data: "c\nd"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := wsParse([]byte(tt.txt)) + got, ok := Parse([]byte(tt.txt)) if !ok || tt.want != got { - t.Errorf("wsParse(%q): got %q want %q", tt.txt, got, tt.want) + t.Errorf("Parse(%q): got %q want %q", tt.txt, got, tt.want) } }) } @@ -143,9 +144,9 @@ func Test_wsParse_IncompleteFails(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := wsParse(tt.txt) + got, ok := Parse(tt.txt) if ok || !reflect.DeepEqual(got, WsMsg{}) { - t.Errorf("wsParse(%q): got %q wanted wsMsg{}", tt.txt, got) + t.Errorf("Parse(%q): got %q wanted wsMsg{}", tt.txt, got) } }) } @@ -156,7 +157,7 @@ func Fuzz_wsParse(f *testing.F) { f.Add([]byte("Click\t\t\" \\n\"\n")) f.Add([]byte("Inner\tJid.1\t\"data\\nline\"\n")) f.Fuzz(func(t *testing.T, a []byte) { - if msg, ok := wsParse(a); ok { + if msg, ok := Parse(a); ok { b := msg.Append(nil) if !bytes.Equal(a, b) { t.Errorf("%q != %q", string(a), string(b)) @@ -176,7 +177,7 @@ func Test_wsMsg_FillAlert(t *testing.T) { err error want string }{ - {"ErrEventUnhandled", ErrEventUnhandled, "Alert\t\t\"danger\\nevent unhandled\"\n"}, + {"event unhandled", errors.New("event unhandled"), "Alert\t\t\"danger\\nevent unhandled\"\n"}, {"escape error text", fooError, "Alert\t\t\"danger\\n<"\"\n"}, } for _, tt := range tests { diff --git a/core/ws.go b/core/ws.go index e760fbe4..d472e9c8 100644 --- a/core/ws.go +++ b/core/ws.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/coder/websocket" + "github.com/linkdata/jaws/core/wire" ) func (rq *Request) startServe() (ok bool) { @@ -44,15 +45,15 @@ func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { ws, err = websocket.Accept(w, r, nil) if err == nil { if err = rq.onConnect(); err == nil { - incomingMsgCh := make(chan WsMsg) + incomingMsgCh := make(chan wire.WsMsg) broadcastMsgCh := rq.Jaws.subscribe(rq, 4+len(rq.elems)*4) - outboundMsgCh := make(chan WsMsg, cap(broadcastMsgCh)) + outboundMsgCh := make(chan wire.WsMsg, cap(broadcastMsgCh)) go wsReader(rq.ctx, rq.cancelFn, rq.Jaws.Done(), incomingMsgCh, ws) // closes incomingMsgCh go wsWriter(rq.ctx, rq.cancelFn, rq.Jaws.Done(), outboundMsgCh, ws) // calls ws.Close() rq.process(broadcastMsgCh, incomingMsgCh, outboundMsgCh) // unsubscribes broadcastMsgCh, closes outboundMsgCh } else { defer ws.Close(websocket.StatusNormalClosure, err.Error()) - var msg WsMsg + var msg wire.WsMsg msg.FillAlert(rq.Jaws.Log(err)) _ = ws.Write(r.Context(), websocket.MessageText, msg.Append(nil)) } @@ -64,14 +65,14 @@ func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { // wsReader reads websocket text messages, parses them and sends them on incomingMsgCh. // // Closes incomingMsgCh on exit. -func wsReader(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, incomingMsgCh chan<- WsMsg, ws *websocket.Conn) { +func wsReader(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, incomingMsgCh chan<- wire.WsMsg, ws *websocket.Conn) { var typ websocket.MessageType var txt []byte var err error defer close(incomingMsgCh) for err == nil { if typ, txt, err = ws.Read(ctx); typ == websocket.MessageText { - if msg, ok := wsParse(txt); ok { + if msg, ok := wire.Parse(txt); ok { select { case <-ctx.Done(): return @@ -90,7 +91,7 @@ func wsReader(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-cha // wsWriter reads JaWS messages from outboundMsgCh, formats them and writes them to the websocket. // // Closes the websocket on exit. -func wsWriter(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, outboundMsgCh <-chan WsMsg, ws *websocket.Conn) { +func wsWriter(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, outboundMsgCh <-chan wire.WsMsg, ws *websocket.Conn) { defer ws.Close(websocket.StatusNormalClosure, "") var err error for err == nil { @@ -114,7 +115,7 @@ func wsWriter(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-cha } } -func wsWriteData(wc io.WriteCloser, firstMsg WsMsg, outboundMsgCh <-chan WsMsg) (err error) { +func wsWriteData(wc io.WriteCloser, firstMsg wire.WsMsg, outboundMsgCh <-chan wire.WsMsg) (err error) { b := firstMsg.Append(nil) // accumulate data to send as long as more messages // are available until it exceeds 32K diff --git a/core/wsmsg.go b/core/wsmsg.go deleted file mode 100644 index 69fef24d..00000000 --- a/core/wsmsg.go +++ /dev/null @@ -1,11 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/core/wire" - -// WsMsg is a message sent to or from a WebSocket. -type WsMsg = wire.WsMsg - -// wsParse parses an incoming text buffer into a message. -func wsParse(txt []byte) (WsMsg, bool) { - return wire.Parse(txt) -} diff --git a/jaws.go b/jaws.go index 6ca6532e..3c40361d 100644 --- a/jaws.go +++ b/jaws.go @@ -6,6 +6,7 @@ import ( core "github.com/linkdata/jaws/core" "github.com/linkdata/jaws/core/assets" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/ui" ) @@ -67,7 +68,7 @@ type ( // Logger matches the log/slog.Logger interface. Logger = core.Logger RWLocker = core.RWLocker - TagGetter = core.TagGetter + TagGetter = tags.TagGetter // NamedBool stores a named boolen value with a HTML representation. NamedBool = core.NamedBool // NamedBoolArray stores the data required to support HTML 'select' elements @@ -75,7 +76,7 @@ type ( // concurrently. NamedBoolArray = core.NamedBoolArray Session = core.Session - Tag = core.Tag + Tag = tags.Tag // TestRequest is a request harness intended for tests. // // Exposed for testing only. @@ -84,16 +85,16 @@ type ( var ( ErrEventUnhandled = core.ErrEventUnhandled - ErrIllegalTagType = core.ErrIllegalTagType // ErrIllegalTagType is returned when a UI tag type is disallowed - ErrNotComparable = core.ErrNotComparable - ErrNotUsableAsTag = core.ErrNotUsableAsTag + ErrIllegalTagType = tags.ErrIllegalTagType // ErrIllegalTagType is returned when a UI tag type is disallowed + ErrNotComparable = tags.ErrNotComparable + ErrNotUsableAsTag = tags.ErrNotUsableAsTag ErrNoWebSocketRequest = core.ErrNoWebSocketRequest ErrPendingCancelled = core.ErrPendingCancelled ErrValueUnchanged = core.ErrValueUnchanged ErrValueNotSettable = core.ErrValueNotSettable ErrRequestAlreadyClaimed = core.ErrRequestAlreadyClaimed ErrJavascriptDisabled = core.ErrJavascriptDisabled - ErrTooManyTags = core.ErrTooManyTags + ErrTooManyTags = tags.ErrTooManyTags ) const ( @@ -109,7 +110,7 @@ var ( // Close when the instance is no longer needed to free associated resources. New = core.New // JawsKeyString returns the string to be used for the given JaWS key. - JawsKeyString = core.JawsKeyString + JawsKeyString = assets.JawsKeyString WriteHTMLTag = core.WriteHTMLTag // HTMLGetterFunc wraps a function and returns a HTMLGetter. HTMLGetterFunc = core.HTMLGetterFunc diff --git a/jawstest/jsvar_test.go b/jawstest/jsvar_test.go index da481daa..6bd85a62 100644 --- a/jawstest/jsvar_test.go +++ b/jawstest/jsvar_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/linkdata/jaws" - core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/ui" "github.com/linkdata/jaws/what" ) @@ -18,12 +18,12 @@ type jsVarValue struct { Number float64 } -func waitMsg(t *testing.T, ch <-chan core.WsMsg) core.WsMsg { +func waitMsg(t *testing.T, ch <-chan wire.WsMsg) wire.WsMsg { t.Helper() select { case <-time.After(2 * time.Second): t.Fatal("timeout waiting for ws message") - return core.WsMsg{} + return wire.WsMsg{} case msg := <-ch: return msg } @@ -85,7 +85,7 @@ func TestJsVar_EventRoundtrip(t *testing.T) { t.Fatal(err) } - rq.InCh <- core.WsMsg{Jid: elem.Jid(), What: what.Set, Data: `={"String":"y","Number":3}`} + rq.InCh <- wire.WsMsg{Jid: elem.Jid(), What: what.Set, Data: `={"String":"y","Number":3}`} msg := waitMsg(t, rq.OutCh) if msg.What != what.Set { diff --git a/ui/jsvar.go b/ui/jsvar.go index 223c575e..750dc981 100644 --- a/ui/jsvar.go +++ b/ui/jsvar.go @@ -13,6 +13,7 @@ import ( core "github.com/linkdata/jaws/core" "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/what" "github.com/linkdata/jq" ) @@ -103,7 +104,7 @@ func (ui *JsVar[T]) setPathLocked(elem *core.Element, jspath string, value any) if err == nil && elem != nil { var data []byte if data, err = json.Marshal(value); err == nil { - elem.Jaws.Broadcast(core.Message{ + elem.Jaws.Broadcast(wire.Message{ Dest: ui.Tag, What: what.Set, Data: jspath + "=" + string(data), diff --git a/ui/template.go b/ui/template.go index a1555d5b..79ed3960 100644 --- a/ui/template.go +++ b/ui/template.go @@ -10,6 +10,7 @@ import ( "github.com/linkdata/deadlock" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/what" ) @@ -28,7 +29,7 @@ var _ core.UI = Template{} // statically ensure interface is defined var _ core.EventHandler = Template{} // statically ensure interface is defined func (t Template) String() string { - return fmt.Sprintf("{%q, %s}", t.Name, core.TagString(t.Dot)) + return fmt.Sprintf("{%q, %s}", t.Name, tags.TagString(t.Dot)) } func findJidOrJsOrHTMLNode(node parse.Node) (found bool) { @@ -117,7 +118,7 @@ func findJidOrJsOrHTMLNode(node parse.Node) (found bool) { func (t Template) JawsRender(e *core.Element, wr io.Writer, params []any) (err error) { var expandedtags []any - if expandedtags, err = core.TagExpand(e.Request, t.Dot); err == nil { + if expandedtags, err = tags.TagExpand(e.Request, t.Dot); err == nil { e.Request.TagExpanded(e, expandedtags) tags, handlers, attrs := core.ParseParams(params) e.Tag(tags...) diff --git a/ui/template_handler_test.go b/ui/template_handler_test.go index 790fdea8..63324baf 100644 --- a/ui/template_handler_test.go +++ b/ui/template_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/linkdata/deadlock" core "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/what" ) @@ -58,7 +59,7 @@ func TestTemplate_RenderUpdateEventAndHelpers(t *testing.T) { var sb bytes.Buffer rw := RequestWriter{Request: rq, Writer: &sb} - if err := rw.Template("uitempl", core.Tag("dot"), "hidden"); err != nil { + if err := rw.Template("uitempl", tags.Tag("dot"), "hidden"); err != nil { t.Fatal(err) } got := sb.String() @@ -86,7 +87,7 @@ func TestTemplate_RenderUpdateEventAndHelpers(t *testing.T) { t.Fatalf("expected event call count 1, got %d", td.events) } - if err := rw.Template("warn", core.Tag("x")); err != nil { + if err := rw.Template("warn", tags.Tag("x")); err != nil { t.Fatal(err) } if deadlock.Debug && log.warns == 0 { @@ -162,7 +163,7 @@ func TestHandler_HandlerServeHTTP(t *testing.T) { defer jw.Close() jw.AddTemplateLookuper(template.Must(template.New("handler").Parse(`{{with $.Dot}}
{{.}}
{{end}}`))) - h := Handler(jw, "handler", core.Tag("ok")) + h := Handler(jw, "handler", tags.Tag("ok")) rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) h.ServeHTTP(rr, req) From a259e54280fda6af02990bc2f715beaed13e28b0 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 14:11:24 +0200 Subject: [PATCH 04/41] wip --- core/bind_test.go | 25 +++++----- core/clickhandler_test.go | 5 +- core/element_test.go | 17 ++++--- core/eventhandler_test.go | 7 +-- core/getter_test.go | 8 ++- core/helpers_test.go | 6 ++- core/htmlgetterfunc_test.go | 6 ++- core/jaws_test.go | 50 ++++++++++--------- core/makehtmlgetter_test.go | 4 +- core/request_test.go | 90 +++++++++++++++++----------------- core/servehttp_test.go | 11 +++-- core/session_test.go | 10 ++-- core/sessioner_test.go | 4 +- core/setter_test.go | 6 ++- core/setterfloat64_test.go | 8 +-- core/stringgetterfunc_test.go | 6 ++- core/tag_testsupport_test.go | 23 +-------- core/testrequestwriter_test.go | 5 +- core/ws_test.go | 44 +++++++++-------- 19 files changed, 174 insertions(+), 161 deletions(-) diff --git a/core/bind_test.go b/core/bind_test.go index 9670ae5d..97369f51 100644 --- a/core/bind_test.go +++ b/core/bind_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/linkdata/deadlock" + tagpkg "github.com/linkdata/jaws/core/tags" ) func TestBind_Hook_Success_panic(t *testing.T) { @@ -76,7 +77,7 @@ func testBind_Hook_Success[T comparable](t *testing.T, testval T) { if calls1 != 1 { t.Error(calls1) } - tags1 := MustTagExpand(nil, bind1) + tags1 := tagpkg.MustTagExpand(nil, bind1) if !reflect.DeepEqual(tags1, []any{&val}) { t.Error(tags1) } @@ -99,7 +100,7 @@ func testBind_Hook_Success[T comparable](t *testing.T, testval T) { if calls2 != 1 { t.Error(calls2) } - tags2 := MustTagExpand(nil, bind2) + tags2 := tagpkg.MustTagExpand(nil, bind2) if !reflect.DeepEqual(tags2, []any{&val}) { t.Error(tags2) } @@ -173,7 +174,7 @@ func testBind_Hook_Set[T comparable](t *testing.T, testval T) { if calls1 != 2 { t.Error(calls1) } - tags1 := MustTagExpand(nil, bind1) + tags1 := tagpkg.MustTagExpand(nil, bind1) if !reflect.DeepEqual(tags1, []any{&val}) { t.Error(tags1) } @@ -191,7 +192,7 @@ func testBind_Hook_Set[T comparable](t *testing.T, testval T) { if calls2 != 0 { t.Error(calls2) } - tags2 := MustTagExpand(nil, bind2) + tags2 := tagpkg.MustTagExpand(nil, bind2) if !reflect.DeepEqual(tags2, []any{&val}) { t.Error(tags2) } @@ -219,7 +220,7 @@ func testBind_Hook_Get[T comparable](t *testing.T, testval T) { if calls1 != 1 { t.Error(calls1) } - tags1 := MustTagExpand(nil, bind1) + tags1 := tagpkg.MustTagExpand(nil, bind1) if !reflect.DeepEqual(tags1, []any{&val}) { t.Error(tags1) } @@ -240,7 +241,7 @@ func testBind_Hook_Get[T comparable](t *testing.T, testval T) { if calls2 != 0 { t.Error(calls2) } - tags2 := MustTagExpand(nil, bind2) + tags2 := tagpkg.MustTagExpand(nil, bind2) if !reflect.DeepEqual(tags2, []any{&val}) { t.Error(tags2) } @@ -279,7 +280,7 @@ func TestBind_Hook_Clicked_binding(t *testing.T) { if gotName != "save" { t.Error(gotName) } - tags := MustTagExpand(nil, bind) + tags := tagpkg.MustTagExpand(nil, bind) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -346,7 +347,7 @@ func TestBind_Hook_Clicked_bindingHook(t *testing.T) { if err := bindWithSuccess.(ClickHandler).JawsClick(nil, "x"); !errors.Is(err, ErrEventUnhandled) { t.Fatal(err) } - tags := MustTagExpand(nil, clickBind2) + tags := tagpkg.MustTagExpand(nil, clickBind2) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -508,7 +509,7 @@ func TestBindFormat(t *testing.T) { if s := getter.JawsGet(nil); s != " 12" { t.Errorf("%q", s) } - tags := MustTagExpand(nil, getter) + tags := tagpkg.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -518,7 +519,7 @@ func TestBindFormat(t *testing.T) { if s := getter.JawsGet(nil); s != " 12" { t.Errorf("%q", s) } - tags = MustTagExpand(nil, getter) + tags = tagpkg.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -537,7 +538,7 @@ func TestBindFormatHTML(t *testing.T) { if s := getter.JawsGetHTML(nil); s != "" { t.Errorf("%q", s) } - tags := MustTagExpand(nil, getter) + tags := tagpkg.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -550,7 +551,7 @@ func TestBindFormatHTML(t *testing.T) { if s := getter.JawsGetHTML(nil); s != "\"\"" { t.Errorf("%q", s) } - tags = MustTagExpand(nil, getter) + tags = tagpkg.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } diff --git a/core/clickhandler_test.go b/core/clickhandler_test.go index fb9bf7bf..03b90270 100644 --- a/core/clickhandler_test.go +++ b/core/clickhandler_test.go @@ -4,6 +4,7 @@ import ( "html/template" "testing" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/what" ) @@ -38,7 +39,7 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { t.Errorf("Request.UI(NewDiv()) = %q, want %q", got, want) } - rq.InCh <- WsMsg{Data: "text", Jid: 1, What: what.Input} + rq.InCh <- wire.WsMsg{Data: "text", Jid: 1, What: what.Input} select { case <-th.C: th.Timeout() @@ -47,7 +48,7 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { default: } - rq.InCh <- WsMsg{Data: "adam", Jid: 1, What: what.Click} + rq.InCh <- wire.WsMsg{Data: "adam", Jid: 1, What: what.Click} select { case <-th.C: th.Timeout() diff --git a/core/element_test.go b/core/element_test.go index 28f9948b..b55fb2e4 100644 --- a/core/element_test.go +++ b/core/element_test.go @@ -13,6 +13,7 @@ import ( "github.com/linkdata/deadlock" "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/what" ) @@ -70,7 +71,7 @@ type testApplyGetterAll struct { initErr error } -func (a testApplyGetterAll) JawsGetTag(tags.Context) any { return Tag("tg") } +func (a testApplyGetterAll) JawsGetTag(tags.Context) any { return tags.Tag("tg") } func (a testApplyGetterAll) JawsClick(*Element, string) error { return ErrEventUnhandled } @@ -102,9 +103,9 @@ func TestElement_Tag(t *testing.T) { tss := &testUi{} e := rq.NewElement(tss) - is.True(!e.HasTag(Tag("zomg"))) - e.Tag(Tag("zomg")) - is.True(e.HasTag(Tag("zomg"))) + is.True(!e.HasTag(tags.Tag("zomg"))) + e.Tag(tags.Tag("zomg")) + is.True(e.HasTag(tags.Tag("zomg"))) s := e.String() if !strings.Contains(s, "zomg") { t.Error(s) @@ -129,7 +130,7 @@ func TestElement_Queued(t *testing.T) { e.Order([]jid.Jid{1, 2}) replaceHTML := template.HTML(fmt.Sprintf("
", e.Jid().String())) e.Replace(replaceHTML) - th.Equal(rq.wsQueue, []WsMsg{ + th.Equal(rq.wsQueue, []wire.WsMsg{ { Data: "hidden\n", Jid: e.jid, @@ -232,7 +233,7 @@ func TestElement_ReplaceMessageTargetsElementHTML(t *testing.T) { // Element.Replace queues directly on the Request, so poke the process loop // once to ensure queued messages are flushed to OutCh in this harness. select { - case rq.InCh <- WsMsg{}: + case rq.InCh <- wire.WsMsg{}: case <-time.After(time.Second): t.Fatal("timeout waking request process loop") } @@ -283,7 +284,7 @@ func TestElement_RenderDebugAndDeletedBranches(t *testing.T) { elem.renderDebug(&sb) rq.mu.Unlock() - elem.Tag(Tag("a"), Tag("b")) + elem.Tag(tags.Tag("a"), tags.Tag("b")) sb.Reset() elem.renderDebug(&sb) if !strings.Contains(sb.String(), ", ") { @@ -319,7 +320,7 @@ func TestElement_ApplyGetterDebugBranches(t *testing.T) { if err != nil { t.Fatalf("unexpected error %v", err) } - if !elem.HasTag(Tag("tg")) { + if !elem.HasTag(tags.Tag("tg")) { t.Fatalf("missing Tag('tg') in %#v", gotTags) } agErr := testApplyGetterAll{initErr: tags.ErrNotComparable} diff --git a/core/eventhandler_test.go b/core/eventhandler_test.go index d22daa18..c8ba4459 100644 --- a/core/eventhandler_test.go +++ b/core/eventhandler_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/what" ) @@ -54,7 +55,7 @@ func (t *testJawsEvent) JawsUpdate(e *Element) { var _ ClickHandler = (*testJawsEvent)(nil) var _ EventHandler = (*testJawsEvent)(nil) -var _ TagGetter = (*testJawsEvent)(nil) +var _ tags.TagGetter = (*testJawsEvent)(nil) var _ UI = (*testJawsEvent)(nil) func Test_JawsEvent_NonClickInvokesJawsEventForDualHandler(t *testing.T) { @@ -69,7 +70,7 @@ func Test_JawsEvent_NonClickInvokesJawsEventForDualHandler(t *testing.T) { zomgItem := &testUi{} id := rq.Register(zomgItem, je, "attr1", []string{"attr2"}, template.HTMLAttr("attr3"), []template.HTMLAttr{"attr4"}) - rq.InCh <- WsMsg{Data: "typed", Jid: id, What: what.Input} + rq.InCh <- wire.WsMsg{Data: "typed", Jid: id, What: what.Input} select { case <-th.C: th.Timeout() @@ -356,7 +357,7 @@ func Test_JawsEvent_ExtraHandler(t *testing.T) { th.NoErr(elem.JawsRender(&sb, []any{je})) th.Equal(sb.String(), "
tjEH
") - rq.InCh <- WsMsg{Data: "name", Jid: 1, What: what.Click} + rq.InCh <- wire.WsMsg{Data: "name", Jid: 1, What: what.Click} select { case <-th.C: th.Timeout() diff --git a/core/getter_test.go b/core/getter_test.go index 19375bfd..14c6cad1 100644 --- a/core/getter_test.go +++ b/core/getter_test.go @@ -1,6 +1,10 @@ package jaws -import "testing" +import ( + "testing" + + "github.com/linkdata/jaws/core/tags" +) func Test_makeGetter_panic(t *testing.T) { defer func() { @@ -24,7 +28,7 @@ func TestMakeGetter_GetterPassThroughAndTag(t *testing.T) { if got := g.JawsGet(nil); got != "x" { t.Fatalf("unexpected getter value %q", got) } - if tag := g.(TagGetter).JawsGetTag(nil); tag != nil { + if tag := g.(tags.TagGetter).JawsGetTag(nil); tag != nil { t.Fatalf("expected nil tag, got %#v", tag) } diff --git a/core/helpers_test.go b/core/helpers_test.go index 1c7f0dbb..3d2eefa3 100644 --- a/core/helpers_test.go +++ b/core/helpers_test.go @@ -4,16 +4,18 @@ import ( "io" "testing" "time" + + "github.com/linkdata/jaws/core/wire" ) -func nextBroadcast(t *testing.T, jw *Jaws) Message { +func nextBroadcast(t *testing.T, jw *Jaws) wire.Message { t.Helper() select { case msg := <-jw.bcastCh: return msg case <-time.After(time.Second): t.Fatal("timeout waiting for broadcast") - return Message{} + return wire.Message{} } } diff --git a/core/htmlgetterfunc_test.go b/core/htmlgetterfunc_test.go index 9bcb58b3..e6710d59 100644 --- a/core/htmlgetterfunc_test.go +++ b/core/htmlgetterfunc_test.go @@ -4,6 +4,8 @@ import ( "html/template" "reflect" "testing" + + "github.com/linkdata/jaws/core/tags" ) func TestHTMLGetterFunc(t *testing.T) { @@ -14,7 +16,7 @@ func TestHTMLGetterFunc(t *testing.T) { if s := hg.JawsGetHTML(nil); s != "foo" { t.Error(s) } - if tags := MustTagExpand(nil, hg); !reflect.DeepEqual(tags, []any{tt}) { - t.Error(tags) + if got := tags.MustTagExpand(nil, hg); !reflect.DeepEqual(got, []any{tt}) { + t.Error(got) } } diff --git a/core/jaws_test.go b/core/jaws_test.go index f655854b..3156dfa1 100644 --- a/core/jaws_test.go +++ b/core/jaws_test.go @@ -13,7 +13,9 @@ import ( "testing" "time" + "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/secureheaders" "github.com/linkdata/jaws/what" ) @@ -21,7 +23,7 @@ import ( type testBroadcastTagGetter struct{} func (testBroadcastTagGetter) JawsGetTag(tags.Context) any { - return Tag("expanded") + return tags.Tag("expanded") } func TestCoverage_GenerateHeadAndConvenienceBroadcasts(t *testing.T) { @@ -106,7 +108,7 @@ func TestBroadcast_ExpandsTagDestBeforeQueue(t *testing.T) { tagger := testBroadcastTagGetter{} - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: tagger, What: what.Inner, Data: "x", @@ -115,12 +117,12 @@ func TestBroadcast_ExpandsTagDestBeforeQueue(t *testing.T) { if msg.What != what.Inner || msg.Data != "x" { t.Fatalf("unexpected msg %#v", msg) } - if got, ok := msg.Dest.(Tag); !ok || got != Tag("expanded") { + if got, ok := msg.Dest.(tags.Tag); !ok || got != tags.Tag("expanded") { t.Fatalf("expected expanded Tag destination, got %T(%#v)", msg.Dest, msg.Dest) } - jw.Broadcast(Message{ - Dest: []any{tagger, Tag("extra")}, + jw.Broadcast(wire.Message{ + Dest: []any{tagger, tags.Tag("extra")}, What: what.Value, Data: "v", }) @@ -132,11 +134,11 @@ func TestBroadcast_ExpandsTagDestBeforeQueue(t *testing.T) { if !ok { t.Fatalf("expected []any destination, got %T(%#v)", msg.Dest, msg.Dest) } - if len(dest) != 2 || dest[0] != Tag("expanded") || dest[1] != Tag("extra") { + if len(dest) != 2 || dest[0] != tags.Tag("expanded") || dest[1] != tags.Tag("extra") { t.Fatalf("unexpected expanded destination %#v", dest) } - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: "html-id", What: what.Delete, }) @@ -153,7 +155,7 @@ func TestBroadcast_NoneDestination(t *testing.T) { } defer jw.Close() - jw.Broadcast(Message{ + jw.Broadcast(wire.Message{ Dest: []any{}, What: what.Update, Data: "x", @@ -173,12 +175,12 @@ func TestBroadcast_ReturnsWhenClosedAndQueueFull(t *testing.T) { } defer jw.Close() - jw.Broadcast(Message{What: what.Alert, Data: "info\nfirst"}) + jw.Broadcast(wire.Message{What: what.Alert, Data: "info\nfirst"}) jw.Close() done := make(chan struct{}) go func() { - jw.Broadcast(Message{What: what.Alert, Data: "info\nsecond"}) + jw.Broadcast(wire.Message{What: what.Alert, Data: "info\nsecond"}) close(done) }() @@ -357,11 +359,11 @@ func TestJaws_distributeDirt_AscendingOrder(t *testing.T) { rq := &Request{} jw.mu.Lock() jw.requests[1] = rq - jw.dirty[Tag("fourth")] = 4 - jw.dirty[Tag("second")] = 2 - jw.dirty[Tag("fifth")] = 5 - jw.dirty[Tag("first")] = 1 - jw.dirty[Tag("third")] = 3 + jw.dirty[tags.Tag("fourth")] = 4 + jw.dirty[tags.Tag("second")] = 2 + jw.dirty[tags.Tag("fifth")] = 5 + jw.dirty[tags.Tag("first")] = 1 + jw.dirty[tags.Tag("third")] = 3 jw.dirtOrder = 5 jw.mu.Unlock() @@ -374,11 +376,11 @@ func TestJaws_distributeDirt_AscendingOrder(t *testing.T) { rq.mu.RUnlock() want := []any{ - Tag("first"), - Tag("second"), - Tag("third"), - Tag("fourth"), - Tag("fifth"), + tags.Tag("first"), + tags.Tag("second"), + tags.Tag("third"), + tags.Tag("fourth"), + tags.Tag("fifth"), } if !reflect.DeepEqual(got, want) { t.Fatalf("dirty tags = %#v, want %#v", got, want) @@ -478,8 +480,8 @@ func TestCoverage_IDAndLookupHelpers(t *testing.T) { func TestCoverage_CookieParseAndIP(t *testing.T) { h := http.Header{} - h.Add("Cookie", `a=1; jaws=`+JawsKeyString(11)+`; x=2`) - h.Add("Cookie", `jaws="`+JawsKeyString(12)+`"`) + h.Add("Cookie", `a=1; jaws=`+assets.JawsKeyString(11)+`; x=2`) + h.Add("Cookie", `jaws="`+assets.JawsKeyString(12)+`"`) h.Add("Cookie", `jaws=not-a-key`) ids := getCookieSessionsIds(h, "jaws") @@ -564,7 +566,7 @@ func TestJaws_ServeWithTimeoutFullSubscriberChannel(t *testing.T) { } defer jw.Close() rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) - msgCh := make(chan Message) // unbuffered: always full when nobody receives + msgCh := make(chan wire.Message) // unbuffered: always full when nobody receives done := make(chan struct{}) go func() { jw.ServeWithTimeout(50 * time.Millisecond) @@ -575,7 +577,7 @@ func TestJaws_ServeWithTimeoutFullSubscriberChannel(t *testing.T) { for i := 0; i <= cap(jw.subCh); i++ { jw.subCh <- subscription{} } - jw.bcastCh <- Message{What: what.Alert, Data: "x"} + jw.bcastCh <- wire.Message{What: what.Alert, Data: "x"} waitUntil := time.Now().Add(time.Second) closed := false diff --git a/core/makehtmlgetter_test.go b/core/makehtmlgetter_test.go index 42bea59e..b36997d4 100644 --- a/core/makehtmlgetter_test.go +++ b/core/makehtmlgetter_test.go @@ -6,6 +6,8 @@ import ( "reflect" "sync/atomic" "testing" + + "github.com/linkdata/jaws/core/tags" ) type testStringer struct{} @@ -103,7 +105,7 @@ func Test_MakeHTMLGetter(t *testing.T) { if txt := got.JawsGetHTML(nil); txt != tt.out { t.Errorf("MakeHTMLGetter(%s).JawsGetHTML() = %v, want %v", tt.name, txt, tt.out) } - if tag := got.(TagGetter).JawsGetTag(nil); tag != tt.tag { + if tag := got.(tags.TagGetter).JawsGetTag(nil); tag != tt.tag { t.Errorf("MakeHTMLGetter(%s).JawsGetTag() = %v, want %v", tt.name, tag, tt.tag) } }) diff --git a/core/request_test.go b/core/request_test.go index b8570594..f83102dd 100644 --- a/core/request_test.go +++ b/core/request_test.go @@ -19,16 +19,18 @@ import ( "time" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/tags" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/jid" "github.com/linkdata/jaws/what" ) const testTimeout = time.Second * 3 -func fillWsCh(ch chan WsMsg) { +func fillWsCh(ch chan wire.WsMsg) { for { select { - case ch <- WsMsg{}: + case ch <- wire.WsMsg{}: default: return } @@ -68,10 +70,10 @@ func TestRequest_Registrations(t *testing.T) { x := &testUi{} - is.Equal(rq.wantMessage(&Message{Dest: x}), false) + is.Equal(rq.wantMessage(&wire.Message{Dest: x}), false) jid := rq.Register(x) is.True(jid.IsValid()) - is.Equal(rq.wantMessage(&Message{Dest: x}), true) + is.Equal(rq.wantMessage(&wire.Message{Dest: x}), true) } func TestRequest_HeadHTML(t *testing.T) { @@ -213,7 +215,7 @@ func TestRequest_SendArrivesOk(t *testing.T) { jid := rq.Register(x) elem := rq.GetElementByJid(jid) is.True(elem != nil) - rq.Jaws.Broadcast(Message{Dest: x, What: what.Inner, Data: "bar"}) + rq.Jaws.Broadcast(wire.Message{Dest: x, What: what.Inner, Data: "bar"}) select { case <-time.NewTimer(time.Hour).C: is.Error("timeout") @@ -221,7 +223,7 @@ func TestRequest_SendArrivesOk(t *testing.T) { elem := rq.GetElementByJid(jid) is.True(elem != nil) if elem != nil { - is.Equal(msg, WsMsg{Jid: elem.jid, Data: "bar", What: what.Inner}) + is.Equal(msg, wire.WsMsg{Jid: elem.jid, Data: "bar", What: what.Inner}) } } } @@ -285,12 +287,12 @@ func TestRequest_SetContextCancellationStopsQueuedEvents(t *testing.T) { select { case <-th.C: th.Timeout() - case rq.InCh <- WsMsg{Jid: jid, What: what.Input, Data: "1"}: + case rq.InCh <- wire.WsMsg{Jid: jid, What: what.Input, Data: "1"}: } select { case <-th.C: th.Timeout() - case rq.InCh <- WsMsg{Jid: jid, What: what.Input, Data: "2"}: + case rq.InCh <- wire.WsMsg{Jid: jid, What: what.Input, Data: "2"}: } select { case <-th.C: @@ -329,7 +331,7 @@ func TestRequest_OutboundRespectsContextDone(t *testing.T) { return errors.New(val) }) fillWsCh(rq.OutCh) - rq.Jaws.Broadcast(Message{Dest: x, What: what.Hook, Data: "bar"}) + rq.Jaws.Broadcast(wire.Message{Dest: x, What: what.Hook, Data: "bar"}) select { case <-th.C: @@ -371,7 +373,7 @@ func TestRequest_Trigger(t *testing.T) { }) // broadcasts from ourselves should not invoke fn - rq.Jaws.Broadcast(Message{Dest: endItem, What: what.Input, Data: ""}) // to know when to stop + rq.Jaws.Broadcast(wire.Message{Dest: endItem, What: what.Input, Data: ""}) // to know when to stop select { case <-th.C: th.Timeout() @@ -383,7 +385,7 @@ func TestRequest_Trigger(t *testing.T) { } // global broadcast should invoke fn - rq.Jaws.Broadcast(Message{Dest: fooItem, What: what.Input, Data: "bar"}) + rq.Jaws.Broadcast(wire.Message{Dest: fooItem, What: what.Input, Data: "bar"}) select { case <-th.C: th.Timeout() @@ -393,12 +395,12 @@ func TestRequest_Trigger(t *testing.T) { } // fn returning error should send an danger alert message - rq.Jaws.Broadcast(Message{Dest: errItem, What: what.Input, Data: "omg"}) + rq.Jaws.Broadcast(wire.Message{Dest: errItem, What: what.Input, Data: "omg"}) select { case <-th.C: th.Timeout() case msg := <-rq.OutCh: - th.Equal(msg.Format(), (&WsMsg{ + th.Equal(msg.Format(), (&wire.WsMsg{ Data: "danger\nomg", Jid: jid.Jid(0), What: what.Alert, @@ -437,7 +439,7 @@ func TestRequest_EventFnQueue(t *testing.T) { }) for i := 0; i < cap(rq.OutCh); i++ { - rq.Jaws.Broadcast(Message{Dest: sleepItem, What: what.Input, Data: strconv.Itoa(i + 1)}) + rq.Jaws.Broadcast(wire.Message{Dest: sleepItem, What: what.Input, Data: strconv.Itoa(i + 1)}) } select { @@ -505,7 +507,7 @@ func TestRequest_EventFnQueueOverflowPanicsWithNoLogger(t *testing.T) { return case <-th.C: th.Timeout() - case rq.InCh <- WsMsg{Jid: jid, What: what.Input}: + case rq.InCh <- wire.WsMsg{Jid: jid, What: what.Input}: } } } @@ -528,7 +530,7 @@ func TestRequest_IgnoresIncomingMsgsDuringShutdown(t *testing.T) { rq.Register(spewItem, func(e *Element, evt what.What, val string) error { atomic.AddInt32(&callCount, 1) if len(rq.OutCh) < cap(rq.OutCh) { - rq.Jaws.Broadcast(Message{Dest: spewItem, What: what.Input}) + rq.Jaws.Broadcast(wire.Message{Dest: spewItem, What: what.Input}) return errors.New("chunks") } atomic.StoreInt32(&spewState, 1) @@ -547,7 +549,7 @@ func TestRequest_IgnoresIncomingMsgsDuringShutdown(t *testing.T) { fooItem := &testUi{} rq.Register(fooItem) - rq.Jaws.Broadcast(Message{Dest: spewItem, What: what.Input}) + rq.Jaws.Broadcast(wire.Message{Dest: spewItem, What: what.Input}) // wait for the event fn to be in hold state waited := 0 @@ -570,10 +572,10 @@ func TestRequest_IgnoresIncomingMsgsDuringShutdown(t *testing.T) { case <-th.C: th.Timeout() default: - rq.Jaws.Broadcast(Message{Dest: rq}) + rq.Jaws.Broadcast(wire.Message{Dest: rq}) } select { - case rq.InCh <- WsMsg{}: + case rq.InCh <- wire.WsMsg{}: case <-rq.DoneCh: th.Fatal() case <-th.C: @@ -672,27 +674,27 @@ func TestRequest_DeleteByTag(t *testing.T) { ui1 := &testUi{} e11 := rq1.NewElement(ui1) th.Equal(e11.jid, Jid(1)) - e11.Tag(Tag("e11"), Tag("foo")) + e11.Tag(tags.Tag("e11"), tags.Tag("foo")) e12 := rq1.NewElement(ui1) th.Equal(e12.jid, Jid(2)) - e12.Tag(Tag("e12")) + e12.Tag(tags.Tag("e12")) e13 := rq1.NewElement(ui1) th.Equal(e13.jid, Jid(3)) - e13.Tag(Tag("e13"), Tag("bar")) + e13.Tag(tags.Tag("e13"), tags.Tag("bar")) rq2 := tj.newRequest(nil) ui2 := &testUi{} e21 := rq2.NewElement(ui2) th.Equal(e21.jid, Jid(4)) - e21.Tag(Tag("e21"), Tag("foo")) + e21.Tag(tags.Tag("e21"), tags.Tag("foo")) e22 := rq2.NewElement(ui2) th.Equal(e22.jid, Jid(5)) - e22.Tag(Tag("e22")) + e22.Tag(tags.Tag("e22")) e23 := rq2.NewElement(ui2) th.Equal(e23.jid, Jid(6)) - e23.Tag(Tag("e23")) + e23.Tag(tags.Tag("e23")) - tj.Delete([]any{Tag("foo"), Tag("bar"), Tag("nothere"), Tag("e23")}) + tj.Delete([]any{tags.Tag("foo"), tags.Tag("bar"), tags.Tag("nothere"), tags.Tag("e23")}) select { case <-th.C: @@ -742,7 +744,7 @@ func TestRequest_HTMLIdBroadcast(t *testing.T) { rq1 := tj.newRequest(nil) rq2 := tj.newRequest(nil) - tj.Broadcast(Message{ + tj.Broadcast(wire.Message{ Dest: "fooId", What: what.Inner, Data: "inner", @@ -951,7 +953,7 @@ func TestRequest_IncomingRemove(t *testing.T) { select { case <-th.C: th.Timeout() - case rq.InCh <- WsMsg{What: what.Remove, Jid: 1, Data: "Jid.1"}: + case rq.InCh <- wire.WsMsg{What: what.Remove, Jid: 1, Data: "Jid.1"}: } elem := rq.GetElementByJid(1) @@ -988,7 +990,7 @@ func TestRequest_IncomingClick(t *testing.T) { select { case <-th.C: th.Timeout() - case rq.InCh <- WsMsg{What: what.Click, Data: "name\tJid.1\tJid.2"}: + case rq.InCh <- wire.WsMsg{What: what.Click, Data: "name\tJid.1\tJid.2"}: } select { @@ -1030,7 +1032,7 @@ func TestRequest_renderDebugLocked(t *testing.T) { tss := &testUi{} e := rq.NewElement(tss) - e.Tag(Tag("zomg")) + e.Tag(tags.Tag("zomg")) var sb strings.Builder e.renderDebug(&sb) @@ -1101,7 +1103,7 @@ func TestCoverage_PendingSubscribeMaintenanceAndParse(t *testing.T) { // done-channel branch in subscribe and unsubscribe. jw.subCh <- subscription{} // fill channel so send case is not selectable - jw.unsubCh <- make(chan Message) + jw.unsubCh <- make(chan wire.Message) jw.Close() if ch := jw.subscribe(nil, 1); ch != nil { t.Fatalf("expected nil subscription after close, got %v", ch) @@ -1194,9 +1196,9 @@ func TestCoverage_RequestProcessHTTPDoneAndBroadcastDone(t *testing.T) { if err := rq.claim(hr); err != nil { t.Fatal(err) } - bcastCh := make(chan Message) - inCh := make(chan WsMsg) - outCh := make(chan WsMsg, 1) + bcastCh := make(chan wire.Message) + inCh := make(chan wire.WsMsg) + outCh := make(chan wire.WsMsg, 1) done := make(chan struct{}) go func() { rq.process(bcastCh, inCh, outCh) @@ -1210,7 +1212,7 @@ func TestCoverage_RequestProcessHTTPDoneAndBroadcastDone(t *testing.T) { } jw.Close() - jw.Broadcast(Message{What: what.Update}) + jw.Broadcast(wire.Message{What: what.Update}) } func TestRequestRecycle_StaleElementIsInert(t *testing.T) { @@ -1321,11 +1323,11 @@ func TestRequest_Template(t *testing.T) { name: "testtemplate-with-tags", args: args{ "testtemplate", - Tag("stringtag1"), - []any{`style="display: none"`, Tag("stringtag2"), "hidden"}, + tags.Tag("stringtag1"), + []any{`style="display: none"`, tags.Tag("stringtag2"), "hidden"}, }, want: ``, - tags: []any{Tag("stringtag1"), Tag("stringtag2")}, + tags: []any{tags.Tag("stringtag1"), tags.Tag("stringtag2")}, errtxt: "", }, } @@ -1386,11 +1388,11 @@ func TestRequest_Template_Event(t *testing.T) { defer rq.Close() dot := &templateDot{clickedCh: make(chan struct{})} rq.Template("testtemplate", dot) - rq.Jaws.Broadcast(Message{ + rq.Jaws.Broadcast(wire.Message{ Dest: dot, What: what.Update, }) - rq.Jaws.Broadcast(Message{ + rq.Jaws.Broadcast(wire.Message{ Dest: dot, What: what.Click, Data: "foo", @@ -1403,14 +1405,14 @@ func TestRequest_Template_Event(t *testing.T) { is.Equal(dot.gotName, "foo") } -func nextOutboundMsg(t *testing.T, rq *testRequest) WsMsg { +func nextOutboundMsg(t *testing.T, rq *testRequest) wire.WsMsg { t.Helper() select { case msg := <-rq.OutCh: return msg case <-time.After(time.Second): t.Fatal("timeout waiting for outbound ws message") - return WsMsg{} + return wire.WsMsg{} } } @@ -1422,7 +1424,7 @@ func TestRequest_IncomingRemoveDoesNotDeleteMessageJid(t *testing.T) { elem := rq.NewElement(&testUi{}) select { - case rq.InCh <- WsMsg{What: what.Remove, Jid: elem.Jid(), Data: ""}: + case rq.InCh <- wire.WsMsg{What: what.Remove, Jid: elem.Jid(), Data: ""}: case <-time.After(time.Second): t.Fatal("timeout sending incoming Remove message") } @@ -1538,7 +1540,7 @@ func TestRequest_IncomingRemoveWithZeroContainerJidIsIgnored(t *testing.T) { elem := rq.NewElement(&testUi{}) select { - case rq.InCh <- WsMsg{What: what.Remove, Jid: 0, Data: elem.Jid().String()}: + case rq.InCh <- wire.WsMsg{What: what.Remove, Jid: 0, Data: elem.Jid().String()}: case <-time.After(time.Second): t.Fatal("timeout sending incoming Remove message") } diff --git a/core/servehttp_test.go b/core/servehttp_test.go index d0f95080..a078dbc5 100644 --- a/core/servehttp_test.go +++ b/core/servehttp_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/staticserve" ) @@ -53,7 +54,7 @@ func TestServeHTTP_GetJavascript(t *testing.T) { mux.ServeHTTP(w, req) is.Equal(w.Code, http.StatusOK) - is.Equal(w.Body.Len(), len(JavascriptText)) + is.Equal(w.Body.Len(), len(assets.JavascriptText)) is.Equal(w.Header()["Cache-Control"], staticserve.HeaderCacheControl) is.Equal(w.Header()["Content-Type"], []string{mime.TypeByExtension(".js")}) is.Equal(w.Header()["Content-Encoding"], nil) @@ -70,13 +71,13 @@ func TestServeHTTP_GetJavascript(t *testing.T) { gr, err := gzip.NewReader(w.Body) is.NoErr(err) - b := make([]byte, len(JavascriptText)+32) + b := make([]byte, len(assets.JavascriptText)+32) n, err := gr.Read(b) b = b[:n] is.NoErr(err) is.NoErr(gr.Close()) - is.Equal(len(JavascriptText), len(b)) - is.Equal(string(JavascriptText), string(b)) + is.Equal(len(assets.JavascriptText), len(b)) + is.Equal(string(assets.JavascriptText), string(b)) } func TestServeHTTP_GetCSS(t *testing.T) { @@ -94,7 +95,7 @@ func TestServeHTTP_GetCSS(t *testing.T) { mux.ServeHTTP(w, req) is.Equal(w.Code, http.StatusOK) - is.Equal(w.Body.Len(), len(JawsCSS)) + is.Equal(w.Body.Len(), len(assets.JawsCSS)) is.Equal(w.Header()["Cache-Control"], staticserve.HeaderCacheControl) is.Equal(w.Header()["Content-Type"], []string{mime.TypeByExtension(".css")}) } diff --git a/core/session_test.go b/core/session_test.go index 52ee580c..1e4e040c 100644 --- a/core/session_test.go +++ b/core/session_test.go @@ -12,6 +12,8 @@ import ( "time" "github.com/coder/websocket" + "github.com/linkdata/jaws/core/assets" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/what" ) @@ -48,7 +50,7 @@ func TestSession_Object(t *testing.T) { if jw.CookieName != cookie.Name { t.Error(cookie.Name) } - if JawsKeyString(sessionId) != cookie.Value { + if assets.JawsKeyString(sessionId) != cookie.Value { t.Error(cookie.Value) } if sessionId != sess.ID() { @@ -315,7 +317,7 @@ func TestSession_Broadcast(t *testing.T) { t.Fatalf("request 2 session mismatch: %v", got) } - msg := Message{What: what.Alert, Data: "info\nhello"} + msg := wire.Message{What: what.Alert, Data: "info\nhello"} done := make(chan struct{}) go func() { sess.Broadcast(msg) @@ -325,7 +327,7 @@ func TestSession_Broadcast(t *testing.T) { msg1 := nextBroadcast(t, jw) msg2 := nextBroadcast(t, jw) - for i, got := range []Message{msg1, msg2} { + for i, got := range []wire.Message{msg1, msg2} { if got.What != msg.What || got.Data != msg.Data { t.Fatalf("message %d mismatch: %#v", i+1, got) } @@ -453,7 +455,7 @@ func TestSession_Delete(t *testing.T) { } defer conn.Close(websocket.StatusNormalClosure, "") - msg := WsMsg{Jid: jidForTag(ts.rq, byebyeItem), What: what.Input} + msg := wire.WsMsg{Jid: jidForTag(ts.rq, byebyeItem), What: what.Input} ctx, cancel := context.WithCancel(ts.ctx) defer cancel() diff --git a/core/sessioner_test.go b/core/sessioner_test.go index 0ab9a855..8f7c48b8 100644 --- a/core/sessioner_test.go +++ b/core/sessioner_test.go @@ -4,6 +4,8 @@ import ( "bytes" "net/http/httptest" "testing" + + "github.com/linkdata/jaws/core/tags" ) func TestJaws_Session(t *testing.T) { @@ -11,7 +13,7 @@ func TestJaws_Session(t *testing.T) { rq := newTestRequest(t) defer rq.Close() - dot := Tag("123") + dot := tags.Tag("123") h := rq.Jaws.Session(rq.Jaws.Handler("testtemplate", dot)) var buf bytes.Buffer diff --git a/core/setter_test.go b/core/setter_test.go index 0b954aa6..dfcea1f0 100644 --- a/core/setter_test.go +++ b/core/setter_test.go @@ -2,6 +2,8 @@ package jaws import ( "testing" + + "github.com/linkdata/jaws/core/tags" ) const testStringGetterText = "" @@ -21,7 +23,7 @@ func Test_makeSetter(t *testing.T) { if s := setter1.JawsGet(nil); s != testStringGetterText { t.Error(s) } - if tag := setter1.(TagGetter).JawsGetTag(nil); tag != tsg { + if tag := setter1.(tags.TagGetter).JawsGetTag(nil); tag != tsg { t.Error(tag) } @@ -32,7 +34,7 @@ func Test_makeSetter(t *testing.T) { if s := setter2.JawsGet(nil); s != "quux" { t.Error(s) } - if tag := setter2.(TagGetter).JawsGetTag(nil); tag != nil { + if tag := setter2.(tags.TagGetter).JawsGetTag(nil); tag != nil { t.Error(tag) } } diff --git a/core/setterfloat64_test.go b/core/setterfloat64_test.go index 4752998a..ab864be1 100644 --- a/core/setterfloat64_test.go +++ b/core/setterfloat64_test.go @@ -3,6 +3,8 @@ package jaws import ( "reflect" "testing" + + "github.com/linkdata/jaws/core/tags" ) type testGetter[T comparable] struct { @@ -70,7 +72,7 @@ func Test_makeSetterFloat64_int(t *testing.T) { if x := gotS.JawsGet(nil); x != 1 { t.Error(x) } - tg := gotS.(TagGetter) + tg := gotS.(tags.TagGetter) if x := tg.JawsGetTag(nil); x != tsint { t.Error(x) } @@ -86,7 +88,7 @@ func Test_makeSetterFloat64ReadOnly_int(t *testing.T) { if x := gotS.JawsGet(nil); x != 1 { t.Error(x) } - tg := gotS.(TagGetter) + tg := gotS.(tags.TagGetter) if x := tg.JawsGetTag(nil); x != tgint { t.Error(x) } @@ -102,7 +104,7 @@ func Test_makeSetterFloat64Static_int(t *testing.T) { if x := gotS.JawsGet(nil); x != 1 { t.Error(x) } - tg := gotS.(TagGetter) + tg := gotS.(tags.TagGetter) if x := tg.JawsGetTag(nil); x != nil { t.Error(x) } diff --git a/core/stringgetterfunc_test.go b/core/stringgetterfunc_test.go index 3df2e039..3c0f35f1 100644 --- a/core/stringgetterfunc_test.go +++ b/core/stringgetterfunc_test.go @@ -3,6 +3,8 @@ package jaws import ( "reflect" "testing" + + "github.com/linkdata/jaws/core/tags" ) func TestStringGetterFunc(t *testing.T) { @@ -13,7 +15,7 @@ func TestStringGetterFunc(t *testing.T) { if s := sg.JawsGet(nil); s != "foo" { t.Error(s) } - if tags := MustTagExpand(nil, sg); !reflect.DeepEqual(tags, []any{tt}) { - t.Error(tags) + if got := tags.MustTagExpand(nil, sg); !reflect.DeepEqual(got, []any{tt}) { + t.Error(got) } } diff --git a/core/tag_testsupport_test.go b/core/tag_testsupport_test.go index 26fd6328..3efa6c56 100644 --- a/core/tag_testsupport_test.go +++ b/core/tag_testsupport_test.go @@ -1,27 +1,8 @@ package jaws -import ( - "github.com/linkdata/jaws/core/assets" - "github.com/linkdata/jaws/core/tags" - "github.com/linkdata/jaws/core/wire" -) +import "github.com/linkdata/jaws/core/tags" -type TagContext = tags.Context -type TagGetter = tags.TagGetter -type Tag = tags.Tag -type Message = wire.Message -type WsMsg = wire.WsMsg - -var MustTagExpand = tags.MustTagExpand -var TagExpand = tags.TagExpand -var TagString = tags.TagString -var JawsKeyAppend = assets.JawsKeyAppend -var JawsKeyString = assets.JawsKeyString -var JawsKeyValue = assets.JawsKeyValue -var JavascriptText = assets.JavascriptText -var JawsCSS = assets.JawsCSS - -func (tt *testSelfTagger) JawsGetTag(TagContext) any { +func (tt *testSelfTagger) JawsGetTag(tags.Context) any { return tt } diff --git a/core/testrequestwriter_test.go b/core/testrequestwriter_test.go index 0733cdbc..4fabb8f2 100644 --- a/core/testrequestwriter_test.go +++ b/core/testrequestwriter_test.go @@ -10,6 +10,7 @@ import ( "text/template/parse" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/what" ) @@ -99,7 +100,7 @@ type testTemplateUI struct { } func (t testTemplateUI) String() string { - return fmt.Sprintf("{%q, %s}", t.Name, TagString(t.Dot)) + return fmt.Sprintf("{%q, %s}", t.Name, tags.TagString(t.Dot)) } func findJidOrJsOrHTMLNode(node parse.Node) (found bool) { @@ -158,7 +159,7 @@ func findJidOrJsOrHTMLNode(node parse.Node) (found bool) { func (t testTemplateUI) JawsRender(e *Element, wr io.Writer, params []any) (err error) { var expandedtags []any - if expandedtags, err = TagExpand(e.Request, t.Dot); err == nil { + if expandedtags, err = tags.TagExpand(e.Request, t.Dot); err == nil { e.Request.TagExpanded(e, expandedtags) tags, handlers, attrs := ParseParams(params) e.Tag(tags...) diff --git a/core/ws_test.go b/core/ws_test.go index e90e37f6..50dafff2 100644 --- a/core/ws_test.go +++ b/core/ws_test.go @@ -14,6 +14,8 @@ import ( "time" "github.com/coder/websocket" + "github.com/linkdata/jaws/core/assets" + "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/what" ) @@ -64,7 +66,7 @@ func (ts *testServer) connected(rq *Request) error { func (ts *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/jaws/") { - jawsKey := JawsKeyValue(strings.TrimPrefix(r.URL.Path, "/jaws/")) + jawsKey := assets.JawsKeyValue(strings.TrimPrefix(r.URL.Path, "/jaws/")) if rq := ts.jw.UseRequest(jawsKey, r); rq != nil { rq.ServeHTTP(w, r) return @@ -245,7 +247,7 @@ func TestWS_NormalExchange(t *testing.T) { } defer conn.Close(websocket.StatusNormalClosure, "") - msg := WsMsg{Jid: jidForTag(ts.rq, fooItem), What: what.Input} + msg := wire.WsMsg{Jid: jidForTag(ts.rq, fooItem), What: what.Input} ctx, cancel := context.WithTimeout(ts.ctx, testTimeout) defer cancel() @@ -266,7 +268,7 @@ func TestWS_NormalExchange(t *testing.T) { if mt != websocket.MessageText { t.Error(mt) } - var m2 WsMsg + var m2 wire.WsMsg m2.FillAlert(fooError) if !bytes.Equal(b, m2.Append(nil)) { t.Error(b) @@ -278,9 +280,9 @@ func TestReader_RespectsContextDone(t *testing.T) { ts := newTestServer() defer ts.Close() - msg := WsMsg{Jid: Jid(1234), What: what.Input} + msg := wire.WsMsg{Jid: Jid(1234), What: what.Input} doneCh := make(chan struct{}) - inCh := make(chan WsMsg) + inCh := make(chan wire.WsMsg) client, server := Pipe() ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) @@ -315,7 +317,7 @@ func TestReader_RespectsJawsDone(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - inCh := make(chan WsMsg) + inCh := make(chan wire.WsMsg) client, server := Pipe() ctx, cancel := context.WithTimeout(context.Background(), testTimeout) @@ -327,7 +329,7 @@ func TestReader_RespectsJawsDone(t *testing.T) { }() ts.jw.Close() - msg := WsMsg{Jid: Jid(1234), What: what.Input} + msg := wire.WsMsg{Jid: Jid(1234), What: what.Input} err := client.Write(ctx, websocket.MessageText, []byte(msg.Format())) if err != nil { t.Error(err) @@ -345,7 +347,7 @@ func TestWriter_SendsThePayload(t *testing.T) { ts := newTestServer() defer ts.Close() - outCh := make(chan WsMsg) + outCh := make(chan wire.WsMsg) defer close(outCh) client, server := Pipe() @@ -361,7 +363,7 @@ func TestWriter_SendsThePayload(t *testing.T) { ts.cancel() }() - msg := WsMsg{Jid: Jid(1234)} + msg := wire.WsMsg{Jid: Jid(1234)} select { case <-th.C: th.Timeout() @@ -396,10 +398,10 @@ func TestWriter_ConcatenatesMessages(t *testing.T) { ts := newTestServer() defer ts.Close() - outCh := make(chan WsMsg, 2) + outCh := make(chan wire.WsMsg, 2) defer close(outCh) - msg := WsMsg{Jid: Jid(1234)} + msg := wire.WsMsg{Jid: Jid(1234)} outCh <- msg outCh <- msg @@ -446,9 +448,9 @@ func TestWriter_ConcatenatesMessagesClosedChannel(t *testing.T) { ts := newTestServer() defer ts.Close() - outCh := make(chan WsMsg, 2) + outCh := make(chan wire.WsMsg, 2) - msg := WsMsg{Jid: Jid(1234)} + msg := wire.WsMsg{Jid: Jid(1234)} outCh <- msg close(outCh) @@ -478,7 +480,7 @@ func TestWriter_ConcatenatesMessagesClosedChannel(t *testing.T) { if mt != websocket.MessageText { t.Error(mt) } - // only the one real message, no zero-value WsMsg appended + // only the one real message, no zero-value wire.WsMsg appended want := msg.Format() if string(b) != want { t.Errorf("got %q, want %q", string(b), want) @@ -497,7 +499,7 @@ func TestWriter_RespectsContext(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan WsMsg) + outCh := make(chan wire.WsMsg) defer close(outCh) client, server := Pipe() client.CloseRead(context.Background()) @@ -523,7 +525,7 @@ func TestWriter_RespectsJawsDone(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan WsMsg) + outCh := make(chan wire.WsMsg) defer close(outCh) client, server := Pipe() client.CloseRead(ts.ctx) @@ -548,7 +550,7 @@ func TestWriter_RespectsOutboundClosed(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan WsMsg) + outCh := make(chan wire.WsMsg) client, server := Pipe() client.CloseRead(ts.ctx) @@ -576,7 +578,7 @@ func TestWriter_ReportsError(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan WsMsg) + outCh := make(chan wire.WsMsg) client, server := Pipe() client.CloseRead(ts.ctx) server.Close(websocket.StatusNormalClosure, "") @@ -586,7 +588,7 @@ func TestWriter_ReportsError(t *testing.T) { wsWriter(ts.rq.ctx, ts.rq.cancelFn, ts.jw.Done(), outCh, server) }() - msg := WsMsg{Jid: Jid(1234)} + msg := wire.WsMsg{Jid: Jid(1234)} select { case <-th.C: th.Timeout() @@ -611,7 +613,7 @@ func TestReader_ReportsError(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - inCh := make(chan WsMsg) + inCh := make(chan wire.WsMsg) client, server := Pipe() client.CloseRead(ts.ctx) server.Close(websocket.StatusNormalClosure, "") @@ -621,7 +623,7 @@ func TestReader_ReportsError(t *testing.T) { wsReader(ts.rq.ctx, ts.rq.cancelFn, ts.jw.Done(), inCh, server) }() - msg := WsMsg{Jid: Jid(1234), What: what.Input} + msg := wire.WsMsg{Jid: Jid(1234), What: what.Input} err := client.Write(ts.ctx, websocket.MessageText, []byte(msg.Format())) if err == nil { t.Fatal("expected error") From b83824920fc7333296a3ed54c4057a90c34a4e94 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 14:11:51 +0200 Subject: [PATCH 05/41] move --- core/LICENSE => LICENSE | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename core/LICENSE => LICENSE (100%) diff --git a/core/LICENSE b/LICENSE similarity index 100% rename from core/LICENSE rename to LICENSE From 8d65a33c6e034c77cc8269d30ee0bcf47a7d4de2 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 14:21:57 +0200 Subject: [PATCH 06/41] wip --- core/wire/wsio.go | 85 +++++++++ core/wire/wsio_test.go | 358 +++++++++++++++++++++++++++++++++++ core/ws.go | 83 +------- core/ws_test.go | 417 ----------------------------------------- 4 files changed, 446 insertions(+), 497 deletions(-) create mode 100644 core/wire/wsio.go create mode 100644 core/wire/wsio_test.go diff --git a/core/wire/wsio.go b/core/wire/wsio.go new file mode 100644 index 00000000..12847270 --- /dev/null +++ b/core/wire/wsio.go @@ -0,0 +1,85 @@ +package wire + +import ( + "context" + "errors" + "io" + + "github.com/coder/websocket" +) + +// ReadLoop reads websocket text messages, parses them, and sends parsed +// messages on incomingMsgCh. +// +// Closes incomingMsgCh on exit. +func ReadLoop(ctx context.Context, ccf context.CancelCauseFunc, doneCh <-chan struct{}, incomingMsgCh chan<- WsMsg, ws *websocket.Conn) { + var typ websocket.MessageType + var txt []byte + var err error + defer close(incomingMsgCh) + for err == nil { + if typ, txt, err = ws.Read(ctx); typ == websocket.MessageText { + if msg, ok := Parse(txt); ok { + select { + case <-ctx.Done(): + return + case <-doneCh: + return + case incomingMsgCh <- msg: + } + } + } + } + if ccf != nil { + ccf(err) + } +} + +// WriteLoop reads messages from outboundMsgCh, formats them, and writes them +// to the websocket. +// +// Closes the websocket on exit. +func WriteLoop(ctx context.Context, ccf context.CancelCauseFunc, doneCh <-chan struct{}, outboundMsgCh <-chan WsMsg, ws *websocket.Conn) { + defer ws.Close(websocket.StatusNormalClosure, "") + var err error + for err == nil { + select { + case <-ctx.Done(): + return + case <-doneCh: + return + case msg, ok := <-outboundMsgCh: + if !ok { + return + } + var wc io.WriteCloser + if wc, err = ws.Writer(ctx, websocket.MessageText); err == nil { + err = writeData(wc, msg, outboundMsgCh) + } + } + } + if ccf != nil { + ccf(err) + } +} + +func writeData(wc io.WriteCloser, firstMsg WsMsg, outboundMsgCh <-chan WsMsg) (err error) { + b := firstMsg.Append(nil) + // accumulate data to send as long as more messages + // are available until it exceeds 32K +batchloop: + for len(b) < 32*1024 { + select { + case msg, ok := <-outboundMsgCh: + if !ok { + break batchloop + } + b = msg.Append(b) + default: + break batchloop + } + } + _, err = wc.Write(b) + err = errors.Join(err, wc.Close()) + return +} diff --git a/core/wire/wsio_test.go b/core/wire/wsio_test.go new file mode 100644 index 00000000..049dbde5 --- /dev/null +++ b/core/wire/wsio_test.go @@ -0,0 +1,358 @@ +package wire + +import ( + "bufio" + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/linkdata/jaws/jid" + "github.com/linkdata/jaws/what" +) + +func TestReadLoop_RespectsContextDone(t *testing.T) { + msg := WsMsg{Jid: jid.Jid(1234), What: what.Input} + inCh := make(chan WsMsg) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + readDoneCh := make(chan struct{}) + go func() { + defer close(readDoneCh) + ReadLoop(ctx, nil, jawsDoneCh, inCh, server) + }() + + writeCtx, writeCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer writeCancel() + if err := client.Write(writeCtx, websocket.MessageText, []byte(msg.Format())); err != nil { + t.Fatal(err) + } + + // ReadLoop should now be blocked trying to send the decoded message. + select { + case <-readDoneCh: + t.Fatal("did not block") + case <-time.After(time.Millisecond): + } + + cancel() + waitDone(t, readDoneCh, "ReadLoop after context cancel") +} + +func TestReadLoop_RespectsDone(t *testing.T) { + msg := WsMsg{Jid: jid.Jid(1234), What: what.Input} + inCh := make(chan WsMsg) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + readDoneCh := make(chan struct{}) + go func() { + defer close(readDoneCh) + ReadLoop(ctx, nil, jawsDoneCh, inCh, server) + }() + + if err := client.Write(ctx, websocket.MessageText, []byte(msg.Format())); err != nil { + t.Fatal(err) + } + close(jawsDoneCh) + waitDone(t, readDoneCh, "ReadLoop after done close") +} + +func TestWriteLoop_SendsThePayload(t *testing.T) { + outCh := make(chan WsMsg) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + writeDoneCh := make(chan struct{}) + go func() { + defer close(writeDoneCh) + WriteLoop(ctx, nil, jawsDoneCh, outCh, server) + }() + + var mt websocket.MessageType + var b []byte + var err error + readDoneCh := make(chan struct{}) + go func() { + defer close(readDoneCh) + readCtx, readCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer readCancel() + mt, b, err = client.Read(readCtx) + }() + + msg := WsMsg{Jid: jid.Jid(1234)} + select { + case outCh <- msg: + case <-time.After(3 * time.Second): + t.Fatal("timeout sending outbound message") + } + + waitDone(t, readDoneCh, "websocket read") + if err != nil { + t.Fatal(err) + } + if mt != websocket.MessageText { + t.Fatal(mt) + } + if string(b) != msg.Format() { + t.Fatal(string(b)) + } + + cancel() + _ = client.CloseNow() + waitDone(t, writeDoneCh, "WriteLoop after context cancel") +} + +func TestWriteLoop_ConcatenatesMessages(t *testing.T) { + outCh := make(chan WsMsg, 2) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + + msg := WsMsg{Jid: jid.Jid(1234)} + outCh <- msg + outCh <- msg + close(outCh) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + writeDoneCh := make(chan struct{}) + go func() { + defer close(writeDoneCh) + WriteLoop(ctx, nil, jawsDoneCh, outCh, server) + }() + + mt, b, err := client.Read(ctx) + if err != nil { + t.Fatal(err) + } + if mt != websocket.MessageText { + t.Fatal(mt) + } + want := msg.Format() + msg.Format() + if string(b) != want { + t.Fatalf("got %q, want %q", string(b), want) + } + _ = client.CloseNow() + waitDone(t, writeDoneCh, "WriteLoop after outbound close") +} + +func TestWriteLoop_ConcatenatesMessagesClosedChannel(t *testing.T) { + outCh := make(chan WsMsg, 2) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + + msg := WsMsg{Jid: jid.Jid(1234)} + outCh <- msg + close(outCh) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + writeDoneCh := make(chan struct{}) + go func() { + defer close(writeDoneCh) + WriteLoop(ctx, nil, jawsDoneCh, outCh, server) + }() + + mt, b, err := client.Read(ctx) + if err != nil { + t.Fatal(err) + } + if mt != websocket.MessageText { + t.Fatal(mt) + } + want := msg.Format() + if string(b) != want { + t.Fatalf("got %q, want %q", string(b), want) + } + _ = client.CloseNow() + waitDone(t, writeDoneCh, "WriteLoop after closed outbound") +} + +func TestWriteLoop_RespectsContext(t *testing.T) { + outCh := make(chan WsMsg) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + client.CloseRead(context.Background()) + + ctx, cancel := context.WithCancel(context.Background()) + writeDoneCh := make(chan struct{}) + go func() { + defer close(writeDoneCh) + WriteLoop(ctx, nil, jawsDoneCh, outCh, server) + }() + + cancel() + waitDone(t, writeDoneCh, "WriteLoop after context cancel") +} + +func TestWriteLoop_RespectsDone(t *testing.T) { + outCh := make(chan WsMsg) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + client.CloseRead(context.Background()) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + writeDoneCh := make(chan struct{}) + go func() { + defer close(writeDoneCh) + WriteLoop(ctx, nil, jawsDoneCh, outCh, server) + }() + + close(jawsDoneCh) + waitDone(t, writeDoneCh, "WriteLoop after done close") +} + +func TestWriteLoop_RespectsOutboundClosed(t *testing.T) { + outCh := make(chan WsMsg) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + client.CloseRead(context.Background()) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + writeDoneCh := make(chan struct{}) + go func() { + defer close(writeDoneCh) + WriteLoop(ctx, nil, jawsDoneCh, outCh, server) + }() + + close(outCh) + waitDone(t, writeDoneCh, "WriteLoop after outbound close") +} + +func TestWriteLoop_ReportsError(t *testing.T) { + outCh := make(chan WsMsg, 1) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + client.CloseRead(context.Background()) + server.CloseNow() + + ctx, cancel := context.WithCancelCause(context.Background()) + writeDoneCh := make(chan struct{}) + go func() { + defer close(writeDoneCh) + WriteLoop(ctx, cancel, jawsDoneCh, outCh, server) + }() + + outCh <- WsMsg{Jid: jid.Jid(1234)} + waitDone(t, writeDoneCh, "WriteLoop after write error") + + err := context.Cause(ctx) + if !errors.Is(err, net.ErrClosed) { + t.Fatalf("%T(%v)", err, err) + } +} + +func TestReadLoop_ReportsError(t *testing.T) { + inCh := make(chan WsMsg) + jawsDoneCh := make(chan struct{}) + client, server := pipe() + defer client.CloseNow() + defer server.CloseNow() + client.CloseRead(context.Background()) + server.CloseNow() + + ctx, cancel := context.WithCancelCause(context.Background()) + readDoneCh := make(chan struct{}) + go func() { + defer close(readDoneCh) + ReadLoop(ctx, cancel, jawsDoneCh, inCh, server) + }() + + waitDone(t, readDoneCh, "ReadLoop after read error") + + err := context.Cause(ctx) + if !errors.Is(err, net.ErrClosed) { + t.Fatalf("%T(%v)", err, err) + } +} + +func waitDone(t *testing.T, doneCh <-chan struct{}, what string) { + t.Helper() + select { + case <-doneCh: + case <-time.After(3 * time.Second): + t.Fatalf("timeout waiting for %s", what) + } +} + +// adapted from nhooyr.io/websocket/internal/test/wstest.Pipe +func pipe() (clientConn, serverConn *websocket.Conn) { + dialOpts := &websocket.DialOptions{ + HTTPClient: &http.Client{ + Transport: fakeTransport{ + h: func(w http.ResponseWriter, r *http.Request) { + serverConn, _ = websocket.Accept(w, r, nil) + }, + }, + }, + } + clientConn, _, _ = websocket.Dial(context.Background(), "ws://localhost", dialOpts) + return clientConn, serverConn +} + +type fakeTransport struct { + h http.HandlerFunc +} + +func (t fakeTransport) RoundTrip(r *http.Request) (*http.Response, error) { + clientConn, serverConn := net.Pipe() + hj := testHijacker{ + ResponseRecorder: httptest.NewRecorder(), + serverConn: serverConn, + } + t.h.ServeHTTP(hj, r) + resp := hj.ResponseRecorder.Result() + if resp.StatusCode == http.StatusSwitchingProtocols { + resp.Body = clientConn + } + return resp, nil +} + +type testHijacker struct { + *httptest.ResponseRecorder + serverConn net.Conn +} + +var _ http.Hijacker = testHijacker{} + +func (hj testHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return hj.serverConn, bufio.NewReadWriter(bufio.NewReader(hj.serverConn), bufio.NewWriter(hj.serverConn)), nil +} diff --git a/core/ws.go b/core/ws.go index d472e9c8..5c3aeac5 100644 --- a/core/ws.go +++ b/core/ws.go @@ -1,9 +1,6 @@ package jaws import ( - "context" - "errors" - "io" "net/http" "strings" @@ -48,9 +45,9 @@ func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { incomingMsgCh := make(chan wire.WsMsg) broadcastMsgCh := rq.Jaws.subscribe(rq, 4+len(rq.elems)*4) outboundMsgCh := make(chan wire.WsMsg, cap(broadcastMsgCh)) - go wsReader(rq.ctx, rq.cancelFn, rq.Jaws.Done(), incomingMsgCh, ws) // closes incomingMsgCh - go wsWriter(rq.ctx, rq.cancelFn, rq.Jaws.Done(), outboundMsgCh, ws) // calls ws.Close() - rq.process(broadcastMsgCh, incomingMsgCh, outboundMsgCh) // unsubscribes broadcastMsgCh, closes outboundMsgCh + go wire.ReadLoop(rq.ctx, rq.cancelFn, rq.Jaws.Done(), incomingMsgCh, ws) // closes incomingMsgCh + go wire.WriteLoop(rq.ctx, rq.cancelFn, rq.Jaws.Done(), outboundMsgCh, ws) // calls ws.Close() + rq.process(broadcastMsgCh, incomingMsgCh, outboundMsgCh) // unsubscribes broadcastMsgCh, closes outboundMsgCh } else { defer ws.Close(websocket.StatusNormalClosure, err.Error()) var msg wire.WsMsg @@ -61,77 +58,3 @@ func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { rq.cancel(err) } } - -// wsReader reads websocket text messages, parses them and sends them on incomingMsgCh. -// -// Closes incomingMsgCh on exit. -func wsReader(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, incomingMsgCh chan<- wire.WsMsg, ws *websocket.Conn) { - var typ websocket.MessageType - var txt []byte - var err error - defer close(incomingMsgCh) - for err == nil { - if typ, txt, err = ws.Read(ctx); typ == websocket.MessageText { - if msg, ok := wire.Parse(txt); ok { - select { - case <-ctx.Done(): - return - case <-jawsDoneCh: - return - case incomingMsgCh <- msg: - } - } - } - } - if ccf != nil { - ccf(err) - } -} - -// wsWriter reads JaWS messages from outboundMsgCh, formats them and writes them to the websocket. -// -// Closes the websocket on exit. -func wsWriter(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, outboundMsgCh <-chan wire.WsMsg, ws *websocket.Conn) { - defer ws.Close(websocket.StatusNormalClosure, "") - var err error - for err == nil { - select { - case <-ctx.Done(): - return - case <-jawsDoneCh: - return - case msg, ok := <-outboundMsgCh: - if !ok { - return - } - var wc io.WriteCloser - if wc, err = ws.Writer(ctx, websocket.MessageText); err == nil { - err = wsWriteData(wc, msg, outboundMsgCh) - } - } - } - if ccf != nil { - ccf(err) - } -} - -func wsWriteData(wc io.WriteCloser, firstMsg wire.WsMsg, outboundMsgCh <-chan wire.WsMsg) (err error) { - b := firstMsg.Append(nil) - // accumulate data to send as long as more messages - // are available until it exceeds 32K -batchloop: - for len(b) < 32*1024 { - select { - case msg, ok := <-outboundMsgCh: - if !ok { - break batchloop - } - b = msg.Append(b) - default: - break batchloop - } - } - _, err = wc.Write(b) - err = errors.Join(err, wc.Close()) - return -} diff --git a/core/ws_test.go b/core/ws_test.go index 50dafff2..498feb74 100644 --- a/core/ws_test.go +++ b/core/ws_test.go @@ -1,11 +1,9 @@ package jaws import ( - "bufio" "bytes" "context" "errors" - "net" "net/http" "net/http/httptest" "net/url" @@ -274,418 +272,3 @@ func TestWS_NormalExchange(t *testing.T) { t.Error(b) } } - -func TestReader_RespectsContextDone(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - msg := wire.WsMsg{Jid: Jid(1234), What: what.Input} - doneCh := make(chan struct{}) - inCh := make(chan wire.WsMsg) - client, server := Pipe() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) - defer cancel() - - go func() { - defer close(doneCh) - wsReader(ts.ctx, nil, ts.jw.Done(), inCh, server) - }() - - client.Write(ctx, websocket.MessageText, []byte(msg.Format())) - - // wsReader should now be blocked trying to send the decoded message - select { - case <-doneCh: - t.Error("did not block") - case <-time.NewTimer(time.Millisecond).C: - } - - ts.cancel() - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } -} - -func TestReader_RespectsJawsDone(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - doneCh := make(chan struct{}) - inCh := make(chan wire.WsMsg) - client, server := Pipe() - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - go func() { - defer close(doneCh) - wsReader(ts.ctx, nil, ts.jw.Done(), inCh, server) - }() - - ts.jw.Close() - msg := wire.WsMsg{Jid: Jid(1234), What: what.Input} - err := client.Write(ctx, websocket.MessageText, []byte(msg.Format())) - if err != nil { - t.Error(err) - } - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } -} - -func TestWriter_SendsThePayload(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - outCh := make(chan wire.WsMsg) - defer close(outCh) - client, server := Pipe() - - go wsWriter(ts.ctx, nil, ts.jw.Done(), outCh, server) - - var mt websocket.MessageType - var b []byte - var err error - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - mt, b, err = client.Read(ts.ctx) - ts.cancel() - }() - - msg := wire.WsMsg{Jid: Jid(1234)} - select { - case <-th.C: - th.Timeout() - case outCh <- msg: - } - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } - - if err != nil { - t.Error(err) - } - if mt != websocket.MessageText { - t.Error(mt) - } - if string(b) != msg.Format() { - t.Error(string(b)) - } - - select { - case <-th.C: - th.Timeout() - case <-client.CloseRead(ts.ctx).Done(): - } -} - -func TestWriter_ConcatenatesMessages(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - outCh := make(chan wire.WsMsg, 2) - defer close(outCh) - - msg := wire.WsMsg{Jid: Jid(1234)} - outCh <- msg - outCh <- msg - - client, server := Pipe() - - go wsWriter(ts.ctx, nil, ts.jw.Done(), outCh, server) - - var mt websocket.MessageType - var b []byte - var err error - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - mt, b, err = client.Read(ts.ctx) - ts.cancel() - }() - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } - - if err != nil { - t.Error(err) - } - if mt != websocket.MessageText { - t.Error(mt) - } - want := msg.Format() + msg.Format() - if string(b) != want { - t.Error(string(b)) - } - - select { - case <-th.C: - th.Timeout() - case <-client.CloseRead(ts.ctx).Done(): - } -} - -func TestWriter_ConcatenatesMessagesClosedChannel(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - outCh := make(chan wire.WsMsg, 2) - - msg := wire.WsMsg{Jid: Jid(1234)} - outCh <- msg - close(outCh) - - client, server := Pipe() - - go wsWriter(ts.ctx, nil, ts.jw.Done(), outCh, server) - - var mt websocket.MessageType - var b []byte - var err error - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - mt, b, err = client.Read(ts.ctx) - ts.cancel() - }() - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } - - if err != nil { - t.Error(err) - } - if mt != websocket.MessageText { - t.Error(mt) - } - // only the one real message, no zero-value wire.WsMsg appended - want := msg.Format() - if string(b) != want { - t.Errorf("got %q, want %q", string(b), want) - } - - select { - case <-th.C: - th.Timeout() - case <-client.CloseRead(ts.ctx).Done(): - } -} - -func TestWriter_RespectsContext(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - doneCh := make(chan struct{}) - outCh := make(chan wire.WsMsg) - defer close(outCh) - client, server := Pipe() - client.CloseRead(context.Background()) - - go func() { - defer close(doneCh) - wsWriter(ts.ctx, nil, ts.jw.Done(), outCh, server) - }() - - ts.cancel() - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - return - } -} - -func TestWriter_RespectsJawsDone(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - doneCh := make(chan struct{}) - outCh := make(chan wire.WsMsg) - defer close(outCh) - client, server := Pipe() - client.CloseRead(ts.ctx) - - go func() { - defer close(doneCh) - wsWriter(ts.ctx, nil, ts.jw.Done(), outCh, server) - }() - - ts.jw.Close() - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } -} - -func TestWriter_RespectsOutboundClosed(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - doneCh := make(chan struct{}) - outCh := make(chan wire.WsMsg) - client, server := Pipe() - client.CloseRead(ts.ctx) - - go func() { - defer close(doneCh) - wsWriter(ts.ctx, nil, ts.jw.Done(), outCh, server) - }() - - close(outCh) - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } - - if err := ts.rq.Context().Err(); err != nil { - t.Error(err) - } -} - -func TestWriter_ReportsError(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - doneCh := make(chan struct{}) - outCh := make(chan wire.WsMsg) - client, server := Pipe() - client.CloseRead(ts.ctx) - server.Close(websocket.StatusNormalClosure, "") - - go func() { - defer close(doneCh) - wsWriter(ts.rq.ctx, ts.rq.cancelFn, ts.jw.Done(), outCh, server) - }() - - msg := wire.WsMsg{Jid: Jid(1234)} - select { - case <-th.C: - th.Timeout() - case outCh <- msg: - } - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } - - err := context.Cause(ts.rq.Context()) - if !errors.Is(err, net.ErrClosed) { - t.Errorf("%T(%v)", err, err) - } -} - -func TestReader_ReportsError(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - doneCh := make(chan struct{}) - inCh := make(chan wire.WsMsg) - client, server := Pipe() - client.CloseRead(ts.ctx) - server.Close(websocket.StatusNormalClosure, "") - - go func() { - defer close(doneCh) - wsReader(ts.rq.ctx, ts.rq.cancelFn, ts.jw.Done(), inCh, server) - }() - - msg := wire.WsMsg{Jid: Jid(1234), What: what.Input} - err := client.Write(ts.ctx, websocket.MessageText, []byte(msg.Format())) - if err == nil { - t.Fatal("expected error") - } - - select { - case <-th.C: - th.Timeout() - case <-doneCh: - } - - err = context.Cause(ts.rq.Context()) - if !errors.Is(err, net.ErrClosed) { - t.Errorf("%T(%v)", err, err) - } -} - -// adapted from nhooyr.io/websocket/internal/test/wstest.Pipe - -func Pipe() (clientConn, serverConn *websocket.Conn) { - dialOpts := &websocket.DialOptions{ - HTTPClient: &http.Client{ - Transport: fakeTransport{ - h: func(w http.ResponseWriter, r *http.Request) { - serverConn, _ = websocket.Accept(w, r, nil) - }, - }, - }, - } - - clientConn, _, _ = websocket.Dial(context.Background(), "ws://localhost", dialOpts) - return clientConn, serverConn -} - -type fakeTransport struct { - h http.HandlerFunc -} - -func (t fakeTransport) RoundTrip(r *http.Request) (*http.Response, error) { - clientConn, serverConn := net.Pipe() - - hj := testHijacker{ - ResponseRecorder: httptest.NewRecorder(), - serverConn: serverConn, - } - - t.h.ServeHTTP(hj, r) - - resp := hj.ResponseRecorder.Result() - if resp.StatusCode == http.StatusSwitchingProtocols { - resp.Body = clientConn - } - return resp, nil -} - -type testHijacker struct { - *httptest.ResponseRecorder - serverConn net.Conn -} - -var _ http.Hijacker = testHijacker{} - -func (hj testHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { - return hj.serverConn, bufio.NewReadWriter(bufio.NewReader(hj.serverConn), bufio.NewWriter(hj.serverConn)), nil -} From 756a2bcc1dbfe7c301bbafd582ffaac814319b17 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 14:23:22 +0200 Subject: [PATCH 07/41] wip --- core/request.go | 52 ++++++++ core/request_test.go | 258 ++++++++++++++++++++++++++++++++++++++++ core/ws.go | 60 ---------- core/ws_test.go | 274 ------------------------------------------- 4 files changed, 310 insertions(+), 334 deletions(-) delete mode 100644 core/ws.go delete mode 100644 core/ws_test.go diff --git a/core/request.go b/core/request.go index 41350a6f..9c041877 100644 --- a/core/request.go +++ b/core/request.go @@ -18,6 +18,7 @@ import ( "sync/atomic" "time" + "github.com/coder/websocket" "github.com/linkdata/deadlock" "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/core/tags" @@ -929,3 +930,54 @@ func (rq *Request) MustLog(err error) { } jw.MustLog(err) } + +func (rq *Request) startServe() (ok bool) { + return rq.claimed.Load() && rq.running.CompareAndSwap(false, true) +} + +func (rq *Request) stopServe() { + rq.cancel(nil) + rq.Jaws.recycle(rq) +} + +var headerContentTypeJavaScript = []string{"text/javascript"} + +// ServeHTTP implements http.HanderFunc. +// +// Requires UseRequest() have been successfully called for the Request. +func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if rq.startServe() { + defer rq.stopServe() + if strings.HasSuffix(r.URL.Path, "/noscript") { + w.WriteHeader(http.StatusNoContent) + rq.cancel(ErrJavascriptDisabled) + return + } + var err error + if r.Header.Get("Sec-WebSocket-Key") != "" { + if err = rq.validateWebSocketOrigin(r); err != nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + rq.cancel(err) + return + } + } + var ws *websocket.Conn + ws, err = websocket.Accept(w, r, nil) + if err == nil { + if err = rq.onConnect(); err == nil { + incomingMsgCh := make(chan wire.WsMsg) + broadcastMsgCh := rq.Jaws.subscribe(rq, 4+len(rq.elems)*4) + outboundMsgCh := make(chan wire.WsMsg, cap(broadcastMsgCh)) + go wire.ReadLoop(rq.ctx, rq.cancelFn, rq.Jaws.Done(), incomingMsgCh, ws) // closes incomingMsgCh + go wire.WriteLoop(rq.ctx, rq.cancelFn, rq.Jaws.Done(), outboundMsgCh, ws) // calls ws.Close() + rq.process(broadcastMsgCh, incomingMsgCh, outboundMsgCh) // unsubscribes broadcastMsgCh, closes outboundMsgCh + } else { + defer ws.Close(websocket.StatusNormalClosure, err.Error()) + var msg wire.WsMsg + msg.FillAlert(rq.Jaws.Log(err)) + _ = ws.Write(r.Context(), websocket.MessageText, msg.Append(nil)) + } + } + rq.cancel(err) + } +} diff --git a/core/request_test.go b/core/request_test.go index f83102dd..90430ed7 100644 --- a/core/request_test.go +++ b/core/request_test.go @@ -18,7 +18,9 @@ import ( "testing" "time" + "github.com/coder/websocket" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/core/assets" "github.com/linkdata/jaws/core/tags" "github.com/linkdata/jaws/core/wire" "github.com/linkdata/jaws/jid" @@ -1554,3 +1556,259 @@ func TestRequest_IncomingRemoveWithZeroContainerJidIsIgnored(t *testing.T) { t.Fatalf("element %s should not be deletable through zero-container Remove", elem.Jid()) } } + +type testServer struct { + jw *Jaws + ctx context.Context + cancel context.CancelFunc + hr *http.Request + rr *httptest.ResponseRecorder + rq *Request + sess *Session + srv *httptest.Server + connectedCh chan struct{} +} + +func newTestServer() (ts *testServer) { + jw, _ := New() + ctx, cancel := context.WithTimeout(context.Background(), time.Hour) + rr := httptest.NewRecorder() + hr := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + sess := jw.NewSession(rr, hr) + rq := jw.NewRequest(hr) + if rq != jw.UseRequest(rq.JawsKey, hr) { + panic("UseRequest failed") + } + ts = &testServer{ + jw: jw, + ctx: ctx, + cancel: cancel, + hr: hr, + rr: rr, + rq: rq, + sess: sess, + connectedCh: make(chan struct{}), + } + rq.SetConnectFn(ts.connected) + ts.srv = httptest.NewServer(ts) + ts.setInitialRequestOrigin() + return +} + +func (ts *testServer) connected(rq *Request) error { + if rq == ts.rq { + close(ts.connectedCh) + } + return nil +} + +func (ts *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/jaws/") { + jawsKey := assets.JawsKeyValue(strings.TrimPrefix(r.URL.Path, "/jaws/")) + if rq := ts.jw.UseRequest(jawsKey, r); rq != nil { + rq.ServeHTTP(w, r) + return + } + } + ts.rq.ServeHTTP(w, r) +} + +func (ts *testServer) Path() string { + return "/jaws/" + ts.rq.JawsKeyString() +} + +func (ts *testServer) Url() string { + return ts.srv.URL + ts.Path() +} + +func (ts *testServer) setInitialRequestOrigin() { + if ts.hr == nil { + return + } + u, err := url.Parse(ts.srv.URL) + if err != nil { + return + } + ts.hr.Host = u.Host + if ts.hr.URL != nil { + ts.hr.URL.Host = u.Host + ts.hr.URL.Scheme = u.Scheme + } +} + +func (ts *testServer) origin() string { + scheme := "http" + if ts.hr != nil && ts.hr.URL != nil && ts.hr.URL.Scheme != "" { + scheme = ts.hr.URL.Scheme + } + host := "" + if ts.hr != nil { + host = ts.hr.Host + } + if host == "" { + if u, err := url.Parse(ts.srv.URL); err == nil { + host = u.Host + if scheme == "" { + scheme = u.Scheme + } + } + } + if scheme == "" { + scheme = "http" + } + return scheme + "://" + host +} + +func (ts *testServer) Dial() (*websocket.Conn, *http.Response, error) { + hdr := http.Header{} + hdr.Set("Origin", ts.origin()) + opts := &websocket.DialOptions{HTTPHeader: hdr} + return websocket.Dial(ts.ctx, ts.Url(), opts) +} + +func (ts *testServer) Close() { + ts.cancel() + ts.srv.Close() + ts.jw.Close() +} + +func TestWS_UpgradeRequired(t *testing.T) { + jw, _ := New() + defer jw.Close() + w := httptest.NewRecorder() + hr := httptest.NewRequest("", "/", nil) + rq := jw.NewRequest(hr) + jw.UseRequest(rq.JawsKey, hr) + req := httptest.NewRequest("", "/jaws/"+rq.JawsKeyString(), nil) + rq.ServeHTTP(w, req) + if w.Code != http.StatusUpgradeRequired { + t.Error(w.Code) + } +} + +func TestWS_RejectsMissingOrigin(t *testing.T) { + ts := newTestServer() + defer ts.Close() + + conn, resp, err := websocket.Dial(ts.ctx, ts.Url(), nil) + if conn != nil { + conn.Close(websocket.StatusNormalClosure, "") + t.Fatal("expected handshake to be rejected") + } + if err == nil { + t.Fatal("expected error") + } + if resp == nil { + t.Fatal("expected response") + } + if resp.Body != nil { + resp.Body.Close() + } + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status %d", resp.StatusCode) + } +} + +func TestWS_RejectsCrossOrigin(t *testing.T) { + ts := newTestServer() + defer ts.Close() + + hdr := http.Header{} + hdr.Set("Origin", "https://evil.invalid") + conn, resp, err := websocket.Dial(ts.ctx, ts.Url(), &websocket.DialOptions{HTTPHeader: hdr}) + if conn != nil { + conn.Close(websocket.StatusNormalClosure, "") + t.Fatal("expected handshake to be rejected") + } + if err == nil { + t.Fatal("expected error") + } + if resp == nil { + t.Fatal("expected response") + } + if resp.Body != nil { + resp.Body.Close() + } + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status %d", resp.StatusCode) + } +} + +func TestWS_ConnectFnFails(t *testing.T) { + const nope = "nope" + ts := newTestServer() + defer ts.Close() + ts.rq.SetConnectFn(func(_ *Request) error { return errors.New(nope) }) + + conn, resp, err := ts.Dial() + if err != nil { + t.Fatal(err) + } + if conn != nil { + defer conn.Close(websocket.StatusNormalClosure, "") + } + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Error(resp.StatusCode) + } + mt, b, err := conn.Read(ts.ctx) + if err != nil { + t.Error(err) + } + if mt != websocket.MessageText { + t.Error(mt) + } + if !strings.Contains(string(b), nope) { + t.Error(string(b)) + } +} + +func TestWS_NormalExchange(t *testing.T) { + th := newTestHelper(t) + ts := newTestServer() + defer ts.Close() + + fooError := errors.New("this foo failed") + + gotCallCh := make(chan struct{}) + fooItem := &testUi{} + testRequestWriter{rq: ts.rq, Writer: httptest.NewRecorder()}.Register(fooItem, func(e *Element, evt what.What, val string) error { + close(gotCallCh) + return fooError + }) + + conn, resp, err := ts.Dial() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Error(resp.StatusCode) + } + defer conn.Close(websocket.StatusNormalClosure, "") + + msg := wire.WsMsg{Jid: jidForTag(ts.rq, fooItem), What: what.Input} + ctx, cancel := context.WithTimeout(ts.ctx, testTimeout) + defer cancel() + + err = conn.Write(ctx, websocket.MessageText, msg.Append(nil)) + if err != nil { + t.Fatal(err) + } + select { + case <-th.C: + th.Timeout() + case <-gotCallCh: + } + + mt, b, err := conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + if mt != websocket.MessageText { + t.Error(mt) + } + var m2 wire.WsMsg + m2.FillAlert(fooError) + if !bytes.Equal(b, m2.Append(nil)) { + t.Error(b) + } +} diff --git a/core/ws.go b/core/ws.go deleted file mode 100644 index 5c3aeac5..00000000 --- a/core/ws.go +++ /dev/null @@ -1,60 +0,0 @@ -package jaws - -import ( - "net/http" - "strings" - - "github.com/coder/websocket" - "github.com/linkdata/jaws/core/wire" -) - -func (rq *Request) startServe() (ok bool) { - return rq.claimed.Load() && rq.running.CompareAndSwap(false, true) -} - -func (rq *Request) stopServe() { - rq.cancel(nil) - rq.Jaws.recycle(rq) -} - -var headerContentTypeJavaScript = []string{"text/javascript"} - -// ServeHTTP implements http.HanderFunc. -// -// Requires UseRequest() have been successfully called for the Request. -func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if rq.startServe() { - defer rq.stopServe() - if strings.HasSuffix(r.URL.Path, "/noscript") { - w.WriteHeader(http.StatusNoContent) - rq.cancel(ErrJavascriptDisabled) - return - } - var err error - if r.Header.Get("Sec-WebSocket-Key") != "" { - if err = rq.validateWebSocketOrigin(r); err != nil { - http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) - rq.cancel(err) - return - } - } - var ws *websocket.Conn - ws, err = websocket.Accept(w, r, nil) - if err == nil { - if err = rq.onConnect(); err == nil { - incomingMsgCh := make(chan wire.WsMsg) - broadcastMsgCh := rq.Jaws.subscribe(rq, 4+len(rq.elems)*4) - outboundMsgCh := make(chan wire.WsMsg, cap(broadcastMsgCh)) - go wire.ReadLoop(rq.ctx, rq.cancelFn, rq.Jaws.Done(), incomingMsgCh, ws) // closes incomingMsgCh - go wire.WriteLoop(rq.ctx, rq.cancelFn, rq.Jaws.Done(), outboundMsgCh, ws) // calls ws.Close() - rq.process(broadcastMsgCh, incomingMsgCh, outboundMsgCh) // unsubscribes broadcastMsgCh, closes outboundMsgCh - } else { - defer ws.Close(websocket.StatusNormalClosure, err.Error()) - var msg wire.WsMsg - msg.FillAlert(rq.Jaws.Log(err)) - _ = ws.Write(r.Context(), websocket.MessageText, msg.Append(nil)) - } - } - rq.cancel(err) - } -} diff --git a/core/ws_test.go b/core/ws_test.go deleted file mode 100644 index 498feb74..00000000 --- a/core/ws_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package jaws - -import ( - "bytes" - "context" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/coder/websocket" - "github.com/linkdata/jaws/core/assets" - "github.com/linkdata/jaws/core/wire" - "github.com/linkdata/jaws/what" -) - -type testServer struct { - jw *Jaws - ctx context.Context - cancel context.CancelFunc - hr *http.Request - rr *httptest.ResponseRecorder - rq *Request - sess *Session - srv *httptest.Server - connectedCh chan struct{} -} - -func newTestServer() (ts *testServer) { - jw, _ := New() - ctx, cancel := context.WithTimeout(context.Background(), time.Hour) - rr := httptest.NewRecorder() - hr := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) - sess := jw.NewSession(rr, hr) - rq := jw.NewRequest(hr) - if rq != jw.UseRequest(rq.JawsKey, hr) { - panic("UseRequest failed") - } - ts = &testServer{ - jw: jw, - ctx: ctx, - cancel: cancel, - hr: hr, - rr: rr, - rq: rq, - sess: sess, - connectedCh: make(chan struct{}), - } - rq.SetConnectFn(ts.connected) - ts.srv = httptest.NewServer(ts) - ts.setInitialRequestOrigin() - return -} - -func (ts *testServer) connected(rq *Request) error { - if rq == ts.rq { - close(ts.connectedCh) - } - return nil -} - -func (ts *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/jaws/") { - jawsKey := assets.JawsKeyValue(strings.TrimPrefix(r.URL.Path, "/jaws/")) - if rq := ts.jw.UseRequest(jawsKey, r); rq != nil { - rq.ServeHTTP(w, r) - return - } - } - ts.rq.ServeHTTP(w, r) -} - -func (ts *testServer) Path() string { - return "/jaws/" + ts.rq.JawsKeyString() -} - -func (ts *testServer) Url() string { - return ts.srv.URL + ts.Path() -} - -func (ts *testServer) setInitialRequestOrigin() { - if ts.hr == nil { - return - } - u, err := url.Parse(ts.srv.URL) - if err != nil { - return - } - ts.hr.Host = u.Host - if ts.hr.URL != nil { - ts.hr.URL.Host = u.Host - ts.hr.URL.Scheme = u.Scheme - } -} - -func (ts *testServer) origin() string { - scheme := "http" - if ts.hr != nil && ts.hr.URL != nil && ts.hr.URL.Scheme != "" { - scheme = ts.hr.URL.Scheme - } - host := "" - if ts.hr != nil { - host = ts.hr.Host - } - if host == "" { - if u, err := url.Parse(ts.srv.URL); err == nil { - host = u.Host - if scheme == "" { - scheme = u.Scheme - } - } - } - if scheme == "" { - scheme = "http" - } - return scheme + "://" + host -} - -func (ts *testServer) Dial() (*websocket.Conn, *http.Response, error) { - hdr := http.Header{} - hdr.Set("Origin", ts.origin()) - opts := &websocket.DialOptions{HTTPHeader: hdr} - return websocket.Dial(ts.ctx, ts.Url(), opts) -} - -func (ts *testServer) Close() { - ts.cancel() - ts.srv.Close() - ts.jw.Close() -} - -func TestWS_UpgradeRequired(t *testing.T) { - jw, _ := New() - defer jw.Close() - w := httptest.NewRecorder() - hr := httptest.NewRequest("", "/", nil) - rq := jw.NewRequest(hr) - jw.UseRequest(rq.JawsKey, hr) - req := httptest.NewRequest("", "/jaws/"+rq.JawsKeyString(), nil) - rq.ServeHTTP(w, req) - if w.Code != http.StatusUpgradeRequired { - t.Error(w.Code) - } -} - -func TestWS_RejectsMissingOrigin(t *testing.T) { - ts := newTestServer() - defer ts.Close() - - conn, resp, err := websocket.Dial(ts.ctx, ts.Url(), nil) - if conn != nil { - conn.Close(websocket.StatusNormalClosure, "") - t.Fatal("expected handshake to be rejected") - } - if err == nil { - t.Fatal("expected error") - } - if resp == nil { - t.Fatal("expected response") - } - if resp.Body != nil { - resp.Body.Close() - } - if resp.StatusCode != http.StatusForbidden { - t.Errorf("status %d", resp.StatusCode) - } -} - -func TestWS_RejectsCrossOrigin(t *testing.T) { - ts := newTestServer() - defer ts.Close() - - hdr := http.Header{} - hdr.Set("Origin", "https://evil.invalid") - conn, resp, err := websocket.Dial(ts.ctx, ts.Url(), &websocket.DialOptions{HTTPHeader: hdr}) - if conn != nil { - conn.Close(websocket.StatusNormalClosure, "") - t.Fatal("expected handshake to be rejected") - } - if err == nil { - t.Fatal("expected error") - } - if resp == nil { - t.Fatal("expected response") - } - if resp.Body != nil { - resp.Body.Close() - } - if resp.StatusCode != http.StatusForbidden { - t.Errorf("status %d", resp.StatusCode) - } -} - -func TestWS_ConnectFnFails(t *testing.T) { - const nope = "nope" - ts := newTestServer() - defer ts.Close() - ts.rq.SetConnectFn(func(_ *Request) error { return errors.New(nope) }) - - conn, resp, err := ts.Dial() - if err != nil { - t.Fatal(err) - } - if conn != nil { - defer conn.Close(websocket.StatusNormalClosure, "") - } - if resp.StatusCode != http.StatusSwitchingProtocols { - t.Error(resp.StatusCode) - } - mt, b, err := conn.Read(ts.ctx) - if err != nil { - t.Error(err) - } - if mt != websocket.MessageText { - t.Error(mt) - } - if !strings.Contains(string(b), nope) { - t.Error(string(b)) - } -} - -func TestWS_NormalExchange(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - fooError := errors.New("this foo failed") - - gotCallCh := make(chan struct{}) - fooItem := &testUi{} - testRequestWriter{rq: ts.rq, Writer: httptest.NewRecorder()}.Register(fooItem, func(e *Element, evt what.What, val string) error { - close(gotCallCh) - return fooError - }) - - conn, resp, err := ts.Dial() - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != http.StatusSwitchingProtocols { - t.Error(resp.StatusCode) - } - defer conn.Close(websocket.StatusNormalClosure, "") - - msg := wire.WsMsg{Jid: jidForTag(ts.rq, fooItem), What: what.Input} - ctx, cancel := context.WithTimeout(ts.ctx, testTimeout) - defer cancel() - - err = conn.Write(ctx, websocket.MessageText, msg.Append(nil)) - if err != nil { - t.Fatal(err) - } - select { - case <-th.C: - th.Timeout() - case <-gotCallCh: - } - - mt, b, err := conn.Read(ctx) - if err != nil { - t.Fatal(err) - } - if mt != websocket.MessageText { - t.Error(mt) - } - var m2 wire.WsMsg - m2.FillAlert(fooError) - if !bytes.Equal(b, m2.Append(nil)) { - t.Error(b) - } -} From 9094b593f3f78d9e4f6125a01cad9db5ac8a00b7 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 31 Mar 2026 14:45:01 +0200 Subject: [PATCH 08/41] wip --- core/{ => bind}/bind.go | 2 +- core/{ => bind}/bind_test.go | 2 +- core/{ => bind}/binder.go | 2 +- core/{ => bind}/binding.go | 2 +- core/{ => bind}/bindinghook.go | 2 +- core/bind/coretypes.go | 17 +++ core/{ => bind}/getter.go | 2 +- core/{ => bind}/getter_test.go | 2 +- core/{ => bind}/htmlgetter.go | 2 +- core/{ => bind}/htmlgetterfunc.go | 2 +- core/{ => bind}/htmlgetterfunc_test.go | 2 +- core/{ => bind}/makehtmlgetter.go | 2 +- core/{ => bind}/makehtmlgetter_test.go | 2 +- core/{ => bind}/rwlocker.go | 2 +- core/{ => bind}/setter.go | 2 +- core/{ => bind}/setter_test.go | 2 +- core/{ => bind}/setterfloat64.go | 2 +- core/{ => bind}/setterfloat64_test.go | 2 +- core/{ => bind}/stringgetterfunc.go | 2 +- core/{ => bind}/stringgetterfunc_test.go | 2 +- core/bind/tag_testsupport_test.go | 9 ++ core/{ => bind}/testsetter_test.go | 2 +- core/element_test.go | 1 - core/{ => htmlx}/writehtml.go | 23 +--- core/{ => htmlx}/writehtml_test.go | 18 +-- core/{ => named}/namedbool.go | 2 +- core/named/namedbool_test.go | 42 ++++++ core/{ => named}/namedboolarray.go | 2 +- core/named/namedboolarray_test.go | 167 +++++++++++++++++++++++ core/{ => named}/namedbooloption.go | 6 +- core/{ => named}/selecthandler.go | 2 +- core/named/testhelpers_test.go | 26 ++++ core/named/types.go | 22 +++ core/named/writehtmlselect.go | 32 +++++ core/namedbool_test.go | 29 ---- core/namedboolarray_test.go | 127 ----------------- core/testhooks.go | 33 +++++ core/{ => testkit}/testsupport.go | 17 ++- core/testsetter_helper_test.go | 124 +++++++++++++++++ core/testsupport_internal_test.go | 80 +++++++++++ core/testuiwidget_test.go | 28 ++-- jaws.go | 42 +++--- ui/a.go | 5 +- ui/button.go | 5 +- ui/checkbox.go | 5 +- ui/constructors_test.go | 16 ++- ui/containerhelper_test.go | 3 +- ui/date.go | 5 +- ui/div.go | 5 +- ui/html_widgets.go | 6 +- ui/html_widgets_test.go | 5 +- ui/img.go | 10 +- ui/input_widgets.go | 18 +-- ui/jsvar.go | 9 +- ui/label.go | 5 +- ui/li.go | 5 +- ui/number.go | 5 +- ui/option.go | 8 +- ui/password.go | 5 +- ui/radio.go | 5 +- ui/radiogroup.go | 5 +- ui/radiogroup_test.go | 3 +- ui/range.go | 5 +- ui/requestwriter_test.go | 3 +- ui/select.go | 12 +- ui/span.go | 5 +- ui/td.go | 5 +- ui/text.go | 5 +- ui/textarea.go | 8 +- ui/tr.go | 5 +- 70 files changed, 746 insertions(+), 324 deletions(-) rename core/{ => bind}/bind.go (96%) rename core/{ => bind}/bind_test.go (99%) rename core/{ => bind}/binder.go (99%) rename core/{ => bind}/binding.go (99%) rename core/{ => bind}/bindinghook.go (99%) create mode 100644 core/bind/coretypes.go rename core/{ => bind}/getter.go (98%) rename core/{ => bind}/getter_test.go (98%) rename core/{ => bind}/htmlgetter.go (93%) rename core/{ => bind}/htmlgetterfunc.go (97%) rename core/{ => bind}/htmlgetterfunc_test.go (96%) rename core/{ => bind}/makehtmlgetter.go (99%) rename core/{ => bind}/makehtmlgetter_test.go (99%) rename core/{ => bind}/rwlocker.go (93%) rename core/{ => bind}/setter.go (98%) rename core/{ => bind}/setter_test.go (99%) rename core/{ => bind}/setterfloat64.go (99%) rename core/{ => bind}/setterfloat64_test.go (99%) rename core/{ => bind}/stringgetterfunc.go (97%) rename core/{ => bind}/stringgetterfunc_test.go (96%) create mode 100644 core/bind/tag_testsupport_test.go rename core/{ => bind}/testsetter_test.go (99%) rename core/{ => htmlx}/writehtml.go (73%) rename core/{ => htmlx}/writehtml_test.go (86%) rename core/{ => named}/namedbool.go (99%) create mode 100644 core/named/namedbool_test.go rename core/{ => named}/namedboolarray.go (99%) create mode 100644 core/named/namedboolarray_test.go rename core/{ => named}/namedbooloption.go (83%) rename core/{ => named}/selecthandler.go (81%) create mode 100644 core/named/testhelpers_test.go create mode 100644 core/named/types.go create mode 100644 core/named/writehtmlselect.go delete mode 100644 core/namedbool_test.go delete mode 100644 core/namedboolarray_test.go create mode 100644 core/testhooks.go rename core/{ => testkit}/testsupport.go (81%) create mode 100644 core/testsetter_helper_test.go create mode 100644 core/testsupport_internal_test.go diff --git a/core/bind.go b/core/bind/bind.go similarity index 96% rename from core/bind.go rename to core/bind/bind.go index 5b3764bb..b7269a19 100644 --- a/core/bind.go +++ b/core/bind/bind.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "sync" diff --git a/core/bind_test.go b/core/bind/bind_test.go similarity index 99% rename from core/bind_test.go rename to core/bind/bind_test.go index 97369f51..70c4613a 100644 --- a/core/bind_test.go +++ b/core/bind/bind_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "errors" diff --git a/core/binder.go b/core/bind/binder.go similarity index 99% rename from core/binder.go rename to core/bind/binder.go index 32ce502e..b56218ec 100644 --- a/core/binder.go +++ b/core/bind/binder.go @@ -1,4 +1,4 @@ -package jaws +package bind import "github.com/linkdata/jaws/core/tags" diff --git a/core/binding.go b/core/bind/binding.go similarity index 99% rename from core/binding.go rename to core/bind/binding.go index 612cbe4b..81ab6f05 100644 --- a/core/binding.go +++ b/core/bind/binding.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "fmt" diff --git a/core/bindinghook.go b/core/bind/bindinghook.go similarity index 99% rename from core/bindinghook.go rename to core/bind/bindinghook.go index d78a2c91..21a67e2d 100644 --- a/core/bindinghook.go +++ b/core/bind/bindinghook.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "fmt" diff --git a/core/bind/coretypes.go b/core/bind/coretypes.go new file mode 100644 index 00000000..e704cb59 --- /dev/null +++ b/core/bind/coretypes.go @@ -0,0 +1,17 @@ +package bind + +import core "github.com/linkdata/jaws/core" + +type ( + // Element is an alias for core.Element. + Element = core.Element + // ClickHandler is an alias for core.ClickHandler. + ClickHandler = core.ClickHandler +) + +var ( + // ErrEventUnhandled is returned by event handlers to pass handling onward. + ErrEventUnhandled = core.ErrEventUnhandled + // ErrValueUnchanged indicates a setter call changed nothing. + ErrValueUnchanged = core.ErrValueUnchanged +) diff --git a/core/getter.go b/core/bind/getter.go similarity index 98% rename from core/getter.go rename to core/bind/getter.go index c8fc3b7e..98c1db15 100644 --- a/core/getter.go +++ b/core/bind/getter.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "errors" diff --git a/core/getter_test.go b/core/bind/getter_test.go similarity index 98% rename from core/getter_test.go rename to core/bind/getter_test.go index 14c6cad1..7d673a24 100644 --- a/core/getter_test.go +++ b/core/bind/getter_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "testing" diff --git a/core/htmlgetter.go b/core/bind/htmlgetter.go similarity index 93% rename from core/htmlgetter.go rename to core/bind/htmlgetter.go index 5ea71324..340d8cb3 100644 --- a/core/htmlgetter.go +++ b/core/bind/htmlgetter.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "html/template" diff --git a/core/htmlgetterfunc.go b/core/bind/htmlgetterfunc.go similarity index 97% rename from core/htmlgetterfunc.go rename to core/bind/htmlgetterfunc.go index 8765da1c..3e5bfea1 100644 --- a/core/htmlgetterfunc.go +++ b/core/bind/htmlgetterfunc.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "html/template" diff --git a/core/htmlgetterfunc_test.go b/core/bind/htmlgetterfunc_test.go similarity index 96% rename from core/htmlgetterfunc_test.go rename to core/bind/htmlgetterfunc_test.go index e6710d59..2dcfecf4 100644 --- a/core/htmlgetterfunc_test.go +++ b/core/bind/htmlgetterfunc_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "html/template" diff --git a/core/makehtmlgetter.go b/core/bind/makehtmlgetter.go similarity index 99% rename from core/makehtmlgetter.go rename to core/bind/makehtmlgetter.go index 9665ca91..488c4dc6 100644 --- a/core/makehtmlgetter.go +++ b/core/bind/makehtmlgetter.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "fmt" diff --git a/core/makehtmlgetter_test.go b/core/bind/makehtmlgetter_test.go similarity index 99% rename from core/makehtmlgetter_test.go rename to core/bind/makehtmlgetter_test.go index b36997d4..00131958 100644 --- a/core/makehtmlgetter_test.go +++ b/core/bind/makehtmlgetter_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "html" diff --git a/core/rwlocker.go b/core/bind/rwlocker.go similarity index 93% rename from core/rwlocker.go rename to core/bind/rwlocker.go index be68809e..bdcfee71 100644 --- a/core/rwlocker.go +++ b/core/bind/rwlocker.go @@ -1,4 +1,4 @@ -package jaws +package bind import "sync" diff --git a/core/setter.go b/core/bind/setter.go similarity index 98% rename from core/setter.go rename to core/bind/setter.go index 0444de44..ee92d0b7 100644 --- a/core/setter.go +++ b/core/bind/setter.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "fmt" diff --git a/core/setter_test.go b/core/bind/setter_test.go similarity index 99% rename from core/setter_test.go rename to core/bind/setter_test.go index dfcea1f0..71f00501 100644 --- a/core/setter_test.go +++ b/core/bind/setter_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "testing" diff --git a/core/setterfloat64.go b/core/bind/setterfloat64.go similarity index 99% rename from core/setterfloat64.go rename to core/bind/setterfloat64.go index d1c558c1..1f548c78 100644 --- a/core/setterfloat64.go +++ b/core/bind/setterfloat64.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "fmt" diff --git a/core/setterfloat64_test.go b/core/bind/setterfloat64_test.go similarity index 99% rename from core/setterfloat64_test.go rename to core/bind/setterfloat64_test.go index ab864be1..5acac055 100644 --- a/core/setterfloat64_test.go +++ b/core/bind/setterfloat64_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "reflect" diff --git a/core/stringgetterfunc.go b/core/bind/stringgetterfunc.go similarity index 97% rename from core/stringgetterfunc.go rename to core/bind/stringgetterfunc.go index 82eb54ac..e76c3d33 100644 --- a/core/stringgetterfunc.go +++ b/core/bind/stringgetterfunc.go @@ -1,4 +1,4 @@ -package jaws +package bind import "github.com/linkdata/jaws/core/tags" diff --git a/core/stringgetterfunc_test.go b/core/bind/stringgetterfunc_test.go similarity index 96% rename from core/stringgetterfunc_test.go rename to core/bind/stringgetterfunc_test.go index 3c0f35f1..8865ed54 100644 --- a/core/stringgetterfunc_test.go +++ b/core/bind/stringgetterfunc_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "reflect" diff --git a/core/bind/tag_testsupport_test.go b/core/bind/tag_testsupport_test.go new file mode 100644 index 00000000..1767f22c --- /dev/null +++ b/core/bind/tag_testsupport_test.go @@ -0,0 +1,9 @@ +package bind + +import "github.com/linkdata/jaws/core/tags" + +func (tt *testSelfTagger) JawsGetTag(tags.Context) any { + return tt +} + +type testSelfTagger struct{} diff --git a/core/testsetter_test.go b/core/bind/testsetter_test.go similarity index 99% rename from core/testsetter_test.go rename to core/bind/testsetter_test.go index f598af9c..3949e48b 100644 --- a/core/testsetter_test.go +++ b/core/bind/testsetter_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "github.com/linkdata/deadlock" diff --git a/core/element_test.go b/core/element_test.go index b55fb2e4..5ae0c1dc 100644 --- a/core/element_test.go +++ b/core/element_test.go @@ -37,7 +37,6 @@ func (tss *testUi) JawsInit(e *Element) (err error) { } var _ UI = (*testUi)(nil) -var _ Setter[string] = (*testUi)(nil) var _ InitHandler = (*testUi)(nil) func (tss *testUi) JawsGet(e *Element) string { diff --git a/core/writehtml.go b/core/htmlx/writehtml.go similarity index 73% rename from core/writehtml.go rename to core/htmlx/writehtml.go index f1202a60..753213d2 100644 --- a/core/writehtml.go +++ b/core/htmlx/writehtml.go @@ -1,4 +1,4 @@ -package jaws +package htmlx import ( "html/template" @@ -75,24 +75,3 @@ func WriteHTMLInner(w io.Writer, jid jid.Jid, htmlTag, typeAttr string, innerHTM } return } - -func WriteHTMLSelect(w io.Writer, jid jid.Jid, nba *NamedBoolArray, attrs []template.HTMLAttr) (err error) { - if err = WriteHTMLTag(w, jid, "select", "", "", attrs); err == nil { - var b []byte - nba.ReadLocked(func(nba []*NamedBool) { - for _, nb := range nba { - b = append(b, "\n