diff --git a/core/LICENSE b/LICENSE similarity index 100% rename from core/LICENSE rename to LICENSE diff --git a/README.md b/README.md index fd9d4afe..8b73f8e4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ go get github.com/linkdata/jaws After the dependency is added, your Go module will be able to import and use JaWS as demonstrated below. -For widget authoring guidance see `ui/README.md`. +For widget authoring guidance see `lib/ui/README.md`. ## Quick start @@ -58,7 +58,8 @@ import ( "sync" "github.com/linkdata/jaws" - "github.com/linkdata/jaws/ui" + "github.com/linkdata/jaws/lib/bind" + "github.com/linkdata/jaws/lib/ui" ) const indexhtml = ` @@ -86,7 +87,7 @@ func main() { var mu sync.Mutex var f float64 - http.DefaultServeMux.Handle("GET /", ui.Handler(jw, "index", jaws.Bind(&mu, &f))) + http.DefaultServeMux.Handle("GET /", ui.Handler(jw, "index", bind.New(&mu, &f))) slog.Error(http.ListenAndServe("localhost:8080", nil).Error()) } ``` @@ -180,7 +181,7 @@ loop (`Serve()` or `ServeWithTimeout()`): `(*Jaws).SessionCount()`, `(*Jaws).Sessions()`, `(*Jaws).Log()`, `(*Jaws).MustLog()`. * Static/ping JaWS endpoints via `(*Jaws).ServeHTTP()`: - `/jaws/.ping`, `/jaws/.jaws.*.js`, `/jaws/.jaws.*.css`. + `/jaws/.ping`, `/jaws/.jaws..js`, `/jaws/.jaws..css`. Broadcasting APIs are not safe before the processing loop starts. In particular, `(*Jaws).Broadcast()` (and helpers that call it), `(*Session).Broadcast()`, @@ -193,12 +194,15 @@ Use `(*Jaws).SecureHeadersMiddleware(next)` to wrap page handlers with a security-header baseline and a `Content-Security-Policy` that matches the resources currently configured for JaWS. +The baseline headers come from +[`github.com/linkdata/secureheaders`](https://github.com/linkdata/secureheaders). + The middleware snapshots `secureheaders.DefaultHeaders`, replaces `Content-Security-Policy` with `jw.ContentSecurityPolicy()`, and does not trust forwarded HTTPS headers. ```go -page := ui.Handler(jw, "index", jaws.Bind(&mu, &f)) +page := ui.Handler(jw, "index", bind.New(&mu, &f)) http.DefaultServeMux.Handle("GET /", jw.SecureHeadersMiddleware(page)) ``` @@ -209,13 +213,13 @@ endpoints to be registered in whichever router you choose to use. All of the endpoints start with "/jaws/", and `Jaws.ServeHTTP()` will handle all of them. -* `/jaws/\.jaws\.[0-9a-z]+\.css` +* `/jaws/.jaws..css` Serves the built-in JaWS stylesheet. The response should be cached indefinitely. -* `/jaws/\.jaws\.[0-9a-z]+\.js` +* `/jaws/.jaws..js` Serves the built-in JaWS client-side JavaScript. @@ -224,7 +228,7 @@ of them. * `/jaws/[0-9a-z]+` (and `/jaws/[0-9a-z]+/noscript`) The WebSocket endpoint. The trailing string must be decoded using - `jaws.JawsKeyValue()` and then the matching JaWS Request retrieved + `assets.JawsKeyValue()` (`github.com/linkdata/jaws/lib/assets`) and then the matching JaWS Request retrieved using the JaWS object's `UseRequest()` method. If the Request is not found, return a **404 Not Found**, otherwise @@ -273,27 +277,27 @@ router.GET("/jaws/*", func(c echo.Context) error { ### HTML rendering -HTML output elements (e.g. `jaws.RequestWriter.Div()`) require a `jaws.HTMLGetter` or something that can -be made into one using `jaws.MakeHTMLGetter()`. +HTML output elements (e.g. `ui.RequestWriter.Div()`) require a `bind.HTMLGetter` or something that can +be made into one using `bind.MakeHTMLGetter()`. In order of precedence, this can be: -* `jaws.HTMLGetter`: `JawsGetHTML(*Element) template.HTML` to be used as-is. -* `jaws.Getter[string]`: `JawsGet(*Element) string` that will be escaped using `html.EscapeString`. -* `jaws.Formatter`: `Format("%v") string` that will be escaped using `html.EscapeString`. +* `bind.HTMLGetter`: `JawsGetHTML(*Element) template.HTML` to be used as-is. +* `bind.Getter[string]`: `JawsGet(*Element) string` that will be escaped using `html.EscapeString`. +* `bind.Formatter`: `Format("%v") string` that will be escaped using `html.EscapeString`. * `fmt.Stringer`: `String() string` that will be escaped using `html.EscapeString`. * a static `template.HTML` or `string` to be used as-is with no HTML escaping. * everything else is rendered using `fmt.Sprint()` and escaped using `html.EscapeString`. -You can use `jaws.Bind().FormatHTML()`, `jaws.HTMLGetterFunc()` or `jaws.StringGetterFunc()` to build a custom renderer +You can use `bind.New(...).FormatHTML()`, `bind.HTMLGetterFunc()` or `bind.StringGetterFunc()` to build a custom renderer for trivial rendering tasks, or define a custom type implementing `HTMLGetter`. ### Data binding -HTML input elements (e.g. `jaws.RequestWriter.Range()`) require bi-directional data flow between the server and the browser. -The first argument to these is usually a `Setter[T]` where `T` is one of `string`, `float64`, `bool` or `time.Time`. It can -also be a `Getter[T]`, in which case the HTML element should be made read-only. +HTML input elements (e.g. `ui.RequestWriter.Range()`) require bi-directional data flow between the server and the browser. +The first argument to these is usually a `bind.Setter[T]` where `T` is one of `string`, `float64`, `bool` or `time.Time`. It can +also be a `bind.Getter[T]`, in which case the HTML element should be made read-only. -Since all data access need to be protected with locks, you will usually use `jaws.Bind()` to create a `jaws.Binder[T]` +Since all data access need to be protected with locks, you will usually use `bind.New()` to create a `bind.Binder[T]` that combines a (RW)Locker and a pointer to a value of type `T`. It also allows you to add chained setters, getters and on-success handlers. @@ -321,7 +325,7 @@ session from a new IP will fail. No data is stored in the client browser except the randomly generated session cookie. You can set the cookie name in `Jaws.CookieName`, the -default is `jaws`. +default is derived from the executable name and falls back to `jaws`. ### A note on the Context @@ -363,7 +367,7 @@ We try to minimize dependencies outside of the standard library. * Browse the [Go package documentation](https://pkg.go.dev/github.com/linkdata/jaws) for an API-by-API overview. -* Inspect the [`example_test.go`](./example_test.go) file for executable +* Inspect the [`example_test.go`](./examples/example_test.go) file for executable examples that can be run with `go test`. * Explore the [demo application](https://github.com/linkdata/jawsdemo) to see a more complete, heavily commented project structure. diff --git a/contracts.go b/contracts.go new file mode 100644 index 00000000..7671c603 --- /dev/null +++ b/contracts.go @@ -0,0 +1,82 @@ +package jaws + +import ( + "html/template" + "io" + + "github.com/linkdata/jaws/lib/what" +) + +type Container interface { + // JawsContains must return a slice of hashable UI objects. The slice contents must not be modified after returning it. + JawsContains(e *Element) (contents []UI) +} + +// InitHandler allows initializing UI getters and setters before their use. +// +// You can of course initialize them in the call from the template engine, +// but at that point you don't have access to the Element, Element.Context +// or Element.Session. +type InitHandler interface { + JawsInit(e *Element) (err error) +} + +// Logger matches the log/slog.Logger interface. +type Logger interface { + Info(msg string, args ...any) + Warn(msg string, args ...any) + Error(msg string, args ...any) +} + +type Renderer interface { + // JawsRender is called once per Element when rendering the initial webpage. + // Do not call this yourself unless it's from within another JawsRender implementation. + JawsRender(e *Element, w io.Writer, params []any) error +} + +// TemplateLookuper resolves a name to a *template.Template. +type TemplateLookuper interface { + Lookup(name string) *template.Template +} + +// UI defines the required methods on JaWS UI objects. +// In addition, all UI objects must be comparable so they can be used as map keys. +type UI interface { + Renderer + Updater +} + +type Updater interface { + // JawsUpdate is called for an Element that has been marked dirty to update it's HTML. + // Do not call this yourself unless it's from within another JawsUpdate implementation. + JawsUpdate(e *Element) +} + +type ClickHandler interface { + // JawsClick is called when an Element's HTML element or something within it + // is clicked in the browser. + // + // The name parameter is taken from the first 'name' HTML attribute or HTML + // 'button' textContent found when traversing the DOM. It may be empty. + JawsClick(e *Element, name string) (err error) +} + +type clickHandlerWrapper struct{ ClickHandler } + +func (chw clickHandlerWrapper) JawsEvent(*Element, what.What, string) error { + return ErrEventUnhandled +} + +type Auth interface { + Data() map[string]any // returns authenticated user data, or nil + Email() string // returns authenticated user email, or an empty string + IsAdmin() bool // return true if admins are defined and current user is one, or if no admins are defined +} + +type MakeAuthFn func(*Request) Auth + +type DefaultAuth struct{} + +func (DefaultAuth) Data() map[string]any { return nil } +func (DefaultAuth) Email() string { return "" } +func (DefaultAuth) IsAdmin() bool { return true } diff --git a/core/clickhandler_test.go b/contracts_test.go similarity index 69% rename from core/clickhandler_test.go rename to contracts_test.go index fb9bf7bf..284f4d1c 100644 --- a/core/clickhandler_test.go +++ b/contracts_test.go @@ -4,7 +4,8 @@ import ( "html/template" "testing" - "github.com/linkdata/jaws/what" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" ) type testJawsClick struct { @@ -13,7 +14,7 @@ type testJawsClick struct { } func (tjc *testJawsClick) JawsClick(e *Element, name string) (err error) { - if err = tjc.err; err == nil { + if err = tjc.Err(); err == nil { tjc.clickCh <- name } return @@ -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() @@ -57,3 +58,16 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { } } } + +func Test_defaultAuth(t *testing.T) { + a := DefaultAuth{} + if a.Data() != nil { + t.Fatal() + } + if a.Email() != "" { + t.Fatal() + } + if a.IsAdmin() != true { + t.Fatal() + } +} diff --git a/core/auth.go b/core/auth.go deleted file mode 100644 index bbaf2852..00000000 --- a/core/auth.go +++ /dev/null @@ -1,15 +0,0 @@ -package jaws - -type Auth interface { - Data() map[string]any // returns authenticated user data, or nil - Email() string // returns authenticated user email, or an empty string - IsAdmin() bool // return true if admins are defined and current user is one, or if no admins are defined -} - -type MakeAuthFn func(*Request) Auth - -type DefaultAuth struct{} - -func (DefaultAuth) Data() map[string]any { return nil } -func (DefaultAuth) Email() string { return "" } -func (DefaultAuth) IsAdmin() bool { return true } diff --git a/core/auth_test.go b/core/auth_test.go deleted file mode 100644 index a1541bb4..00000000 --- a/core/auth_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package jaws - -import ( - "testing" -) - -func Test_defaultAuth(t *testing.T) { - a := DefaultAuth{} - if a.Data() != nil { - t.Fatal() - } - if a.Email() != "" { - t.Fatal() - } - if a.IsAdmin() != true { - t.Fatal() - } -} diff --git a/core/clickhandler.go b/core/clickhandler.go deleted file mode 100644 index ccf94f34..00000000 --- a/core/clickhandler.go +++ /dev/null @@ -1,18 +0,0 @@ -package jaws - -import "github.com/linkdata/jaws/what" - -type ClickHandler interface { - // JawsClick is called when an Element's HTML element or something within it - // is clicked in the browser. - // - // The name parameter is taken from the first 'name' HTML attribute or HTML - // 'button' textContent found when traversing the DOM. It may be empty. - JawsClick(e *Element, name string) (err error) -} - -type clickHandlerWrapper struct{ ClickHandler } - -func (chw clickHandlerWrapper) JawsEvent(*Element, what.What, string) error { - return ErrEventUnhandled -} diff --git a/core/container.go b/core/container.go deleted file mode 100644 index 1a75da7d..00000000 --- a/core/container.go +++ /dev/null @@ -1,6 +0,0 @@ -package jaws - -type Container interface { - // JawsContains must return a slice of hashable UI objects. The slice contents must not be modified after returning it. - JawsContains(e *Element) (contents []UI) -} diff --git a/core/helpers_test.go b/core/helpers_test.go deleted file mode 100644 index 1c7f0dbb..00000000 --- a/core/helpers_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package jaws - -import ( - "io" - "testing" - "time" -) - -func nextBroadcast(t *testing.T, jw *Jaws) Message { - t.Helper() - select { - case msg := <-jw.bcastCh: - return msg - case <-time.After(time.Second): - t.Fatal("timeout waiting for broadcast") - return Message{} - } -} - -type errReader struct{} - -func (errReader) Read([]byte) (int, error) { - return 0, io.EOF -} diff --git a/core/htmlgetterfunc.go b/core/htmlgetterfunc.go deleted file mode 100644 index 629e355e..00000000 --- a/core/htmlgetterfunc.go +++ /dev/null @@ -1,23 +0,0 @@ -package jaws - -import "html/template" - -type htmlGetterFunc struct { - fn func(*Element) template.HTML - tags []any -} - -var _ TagGetter = &htmlGetterFunc{} - -func (g *htmlGetterFunc) JawsGetHTML(e *Element) template.HTML { - return g.fn(e) -} - -func (g *htmlGetterFunc) JawsGetTag(e *Request) any { - return g.tags -} - -// HTMLGetterFunc wraps a function and returns a HTMLGetter. -func HTMLGetterFunc(fn func(elem *Element) (tmpl template.HTML), tags ...any) HTMLGetter { - return &htmlGetterFunc{fn: fn, tags: tags} -} diff --git a/core/htmlgetterfunc_test.go b/core/htmlgetterfunc_test.go deleted file mode 100644 index 9bcb58b3..00000000 --- a/core/htmlgetterfunc_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package jaws - -import ( - "html/template" - "reflect" - "testing" -) - -func TestHTMLGetterFunc(t *testing.T) { - tt := &testSelfTagger{} - hg := HTMLGetterFunc(func(e *Element) template.HTML { - return "foo" - }, tt) - if s := hg.JawsGetHTML(nil); s != "foo" { - t.Error(s) - } - if tags := MustTagExpand(nil, hg); !reflect.DeepEqual(tags, []any{tt}) { - t.Error(tags) - } -} diff --git a/core/inithandler.go b/core/inithandler.go deleted file mode 100644 index 079c4ca3..00000000 --- a/core/inithandler.go +++ /dev/null @@ -1,10 +0,0 @@ -package jaws - -// InitHandler allows initializing UI getters and setters before their use. -// -// You can of course initialize them in the call from the template engine, -// but at that point you don't have access to the Element, Element.Context -// or Element.Session. -type InitHandler interface { - JawsInit(e *Element) (err error) -} diff --git a/core/jaws.go b/core/jaws.go deleted file mode 100644 index b4ec9279..00000000 --- a/core/jaws.go +++ /dev/null @@ -1,889 +0,0 @@ -// package jaws provides a mechanism to create dynamic -// webpages using Javascript and WebSockets. -// -// It integrates well with Go's html/template package, -// but can be used without it. It can be used with any -// router that supports the standard ServeHTTP interface. -package jaws - -import ( - "bufio" - "bytes" - "context" - "crypto/rand" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "html/template" - "io" - "maps" - "net" - "net/http" - "net/netip" - "net/textproto" - "net/url" - "slices" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/linkdata/deadlock" - "github.com/linkdata/jaws/jid" - "github.com/linkdata/jaws/secureheaders" - "github.com/linkdata/jaws/staticserve" - "github.com/linkdata/jaws/what" -) - -const ( - DefaultUpdateInterval = time.Millisecond * 100 // Default browser update interval -) - -// Jid is the identifier type used for HTML elements managed by JaWS. -// -// It is provided as a convenience alias to the value defined in the jid -// subpackage so applications do not have to import that package directly -// when working with element IDs. -type Jid = jid.Jid // convenience alias - -// Jaws holds the server-side state and configuration for a JaWS instance. -// -// A single Jaws value coordinates template lookup, session handling and the -// request lifecycle that keeps the browser and backend synchronized via -// WebSockets. The zero value is not ready for use; construct instances with -// New to ensure the helper goroutines and static assets are prepared. -type Jaws struct { - CookieName string // Name for session cookies, defaults to "jaws" - Logger Logger // Optional logger to use - 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 - subCh chan subscription - unsubCh chan chan Message - updateTicker *time.Ticker - reqPool sync.Pool - serveJS *staticserve.StaticServe - serveCSS *staticserve.StaticServe - mu deadlock.RWMutex // protects following - headPrefix string - faviconURL string - cspHeader string - tmplookers []TemplateLookuper - kg *bufio.Reader - closeCh chan struct{} // closed when Close() has been called - requests map[uint64]*Request - sessions map[uint64]*Session - dirty map[any]int - dirtOrder int -} - -// New allocates a JaWS instance with the default configuration. -// -// The returned Jaws value is ready for use: static assets are embedded, -// internal goroutines are configured and the request pool is primed. Call -// 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 { - tmp := &Jaws{ - CookieName: DefaultCookieName, - BaseContext: context.Background(), - serveJS: serveJS, - serveCSS: serveCSS, - bcastCh: make(chan Message, 1), - subCh: make(chan subscription, 1), - unsubCh: make(chan chan Message, 1), - updateTicker: time.NewTicker(DefaultUpdateInterval), - kg: bufio.NewReader(rand.Reader), - requests: make(map[uint64]*Request), - sessions: make(map[uint64]*Session), - dirty: make(map[any]int), - closeCh: make(chan struct{}), - } - if err = tmp.GenerateHeadHTML(); err == nil { - jw = tmp - jw.reqPool.New = func() any { - return (&Request{ - Jaws: jw, - tagMap: make(map[any][]*Element), - }).clearLocked() - } - } - } - } - - return -} - -// Close frees resources associated with the JaWS object, and -// closes the completion channel if the JaWS was created with New(). -// Once the completion channel is closed, broadcasts and sends may be discarded. -// Subsequent calls to Close() have no effect. -func (jw *Jaws) Close() { - jw.mu.Lock() - select { - case <-jw.closeCh: - // already closed - default: - close(jw.closeCh) - } - jw.updateTicker.Stop() - jw.mu.Unlock() -} - -// Done returns the channel that is closed when Close has been called. -func (jw *Jaws) Done() <-chan struct{} { - return jw.closeCh -} - -// AddTemplateLookuper adds an object that can resolve -// strings to *template.Template. -func (jw *Jaws) AddTemplateLookuper(tl TemplateLookuper) (err error) { - if tl != nil { - if err = newErrNotComparable(tl); err == nil { - jw.mu.Lock() - if !slices.Contains(jw.tmplookers, tl) { - jw.tmplookers = append(jw.tmplookers, tl) - } - jw.mu.Unlock() - } - } - return -} - -// RemoveTemplateLookuper removes the given object from -// the list of TemplateLookupers. -func (jw *Jaws) RemoveTemplateLookuper(tl TemplateLookuper) (err error) { - if tl != nil { - if err = newErrNotComparable(tl); err == nil { - jw.mu.Lock() - jw.tmplookers = slices.DeleteFunc(jw.tmplookers, func(x TemplateLookuper) bool { return x == tl }) - jw.mu.Unlock() - } - } - return -} - -// LookupTemplate queries the known TemplateLookupers in the order -// they were added and returns the first found. -func (jw *Jaws) LookupTemplate(name string) *template.Template { - jw.mu.RLock() - defer jw.mu.RUnlock() - for _, tl := range jw.tmplookers { - if t := tl.Lookup(name); t != nil { - return t - } - } - return nil -} - -// RequestCount returns the number of Requests. -// -// The count includes all Requests, including those being rendered, -// those waiting for the WebSocket callback and those active. -func (jw *Jaws) RequestCount() (n int) { - jw.mu.RLock() - n = len(jw.requests) - jw.mu.RUnlock() - return -} - -// Log sends an error to the Logger set in the Jaws. -// Has no effect if the err is nil or the Logger is nil. -// Returns err. -func (jw *Jaws) Log(err error) error { - if err != nil && jw != nil && jw.Logger != nil { - jw.Logger.Error("jaws", "err", err) - } - return err -} - -// MustLog sends an error to the Logger set in the Jaws or -// panics with the given error if no Logger is set. -// Has no effect if the err is nil. -func (jw *Jaws) MustLog(err error) { - if err != nil { - if jw != nil && jw.Logger != nil { - jw.Logger.Error("jaws", "err", err) - } else { - panic(err) - } - } -} - -// NextID returns an int64 unique within lifetime of the program. -func NextID() int64 { - return atomic.AddInt64((*int64)(&NextJid), 1) -} - -// AppendID appends the result of NextID() in text form to the given slice. -func AppendID(b []byte) []byte { - return strconv.AppendInt(b, NextID(), 32) -} - -// MakeID returns a string in the form 'jaws.X' where X is a unique string within lifetime of the program. -func MakeID() string { - return string(AppendID([]byte("jaws."))) -} - -// NewRequest returns a new pending JaWS request. -// -// Call this as soon as you start processing a HTML request, and store the -// returned Request pointer so it can be used while constructing the HTML -// response in order to register the JaWS id's you use in the response, and -// use it's Key attribute when sending the Javascript portion of the reply. -// -// Automatic timeout handling is performed by ServeWithTimeout. The default -// Serve() helper uses a 10-second timeout. -func (jw *Jaws) NewRequest(hr *http.Request) (rq *Request) { - jw.mu.Lock() - defer jw.mu.Unlock() - for rq == nil { - jawsKey := jw.nonZeroRandomLocked() - if _, ok := jw.requests[jawsKey]; !ok { - rq = jw.getRequestLocked(jawsKey, hr) - jw.requests[jawsKey] = rq - } - } - return -} - -func (jw *Jaws) nonZeroRandomLocked() (val uint64) { - random := make([]byte, 8) - for val == 0 { - if _, err := io.ReadFull(jw.kg, random); err != nil { - panic(err) - } - val = binary.LittleEndian.Uint64(random) - } - return -} - -// UseRequest extracts the JaWS request with the given key from the request -// map if it exists and the HTTP request remote IP matches. -// -// Call it when receiving the WebSocket connection on '/jaws/:key' to get the -// associated Request, and then call it's ServeHTTP method to process the -// WebSocket messages. -// -// Returns nil if the key was not found or the IP doesn't match, in which -// case you should return a HTTP "404 Not Found" status. -func (jw *Jaws) UseRequest(jawsKey uint64, hr *http.Request) (rq *Request) { - if jawsKey != 0 { - var err error - jw.mu.Lock() - if waitingRq, ok := jw.requests[jawsKey]; ok { - if err = waitingRq.claim(hr); err == nil { - rq = waitingRq - } - } - jw.mu.Unlock() - _ = jw.Log(err) - } - return -} - -// SessionCount returns the number of active sessions. -func (jw *Jaws) SessionCount() (n int) { - jw.mu.RLock() - n = len(jw.sessions) - jw.mu.RUnlock() - return -} - -// Sessions returns a list of all active sessions, which may be nil. -func (jw *Jaws) Sessions() (sl []*Session) { - jw.mu.RLock() - if n := len(jw.sessions); n > 0 { - sl = make([]*Session, 0, n) - for _, sess := range jw.sessions { - sl = append(sl, sess) - } - } - jw.mu.RUnlock() - return -} - -func (jw *Jaws) getSessionLocked(sessIds []uint64, remoteIP netip.Addr) *Session { - for _, sessId := range sessIds { - if sess, ok := jw.sessions[sessId]; ok && equalIP(remoteIP, sess.remoteIP) { - if !sess.isDead() { - return sess - } - } - } - return nil -} - -func cutString(s string, sep byte) (before, after string) { - if i := strings.IndexByte(s, sep); i >= 0 { - return s[:i], s[i+1:] - } - return s, "" -} - -func getCookieSessionsIds(h http.Header, wanted string) (cookies []uint64) { - for _, line := range h["Cookie"] { - if strings.Contains(line, wanted) { - var part string - line = textproto.TrimString(line) - for len(line) > 0 { - part, line = cutString(line, ';') - if part = textproto.TrimString(part); part != "" { - name, val := cutString(part, '=') - name = textproto.TrimString(name) - if name == wanted { - if len(val) > 1 && val[0] == '"' && val[len(val)-1] == '"' { - val = val[1 : len(val)-1] - } - if sessId := JawsKeyValue(val); sessId != 0 { - cookies = append(cookies, sessId) - } - } - } - } - } - } - return -} - -// GetSession returns the Session associated with the given *http.Request, or nil. -func (jw *Jaws) GetSession(hr *http.Request) (sess *Session) { - if hr != nil { - if sessIds := getCookieSessionsIds(hr.Header, jw.CookieName); len(sessIds) > 0 { - remoteIP := parseIP(hr.RemoteAddr) - jw.mu.RLock() - sess = jw.getSessionLocked(sessIds, remoteIP) - jw.mu.RUnlock() - } - } - return -} - -// NewSession creates a new Session. -// -// Any pre-existing Session will be cleared and closed. -// This may call Session.Close() on an existing session and therefore requires -// the JaWS processing loop (`Serve()` or `ServeWithTimeout()`) to be running. -// -// Subsequent Requests created with `NewRequest()` that have the cookie set and -// originates from the same IP will be able to access the Session. -func (jw *Jaws) NewSession(w http.ResponseWriter, hr *http.Request) (sess *Session) { - if hr != nil { - if oldSess := jw.GetSession(hr); oldSess != nil { - oldSess.Clear() - oldSess.Close() - } - sess = jw.newSession(w, hr) - } - return -} - -func (jw *Jaws) newSession(w http.ResponseWriter, hr *http.Request) (sess *Session) { - secure := requestIsSecure(hr) - jw.mu.Lock() - defer jw.mu.Unlock() - for sess == nil { - sessionID := jw.nonZeroRandomLocked() - if _, ok := jw.sessions[sessionID]; !ok { - sess = newSession(jw, sessionID, parseIP(hr.RemoteAddr), secure) - jw.sessions[sessionID] = sess - if w != nil { - http.SetCookie(w, &sess.cookie) - } - hr.AddCookie(&sess.cookie) - } - } - return -} - -func (jw *Jaws) deleteSession(sessionID uint64) { - jw.mu.Lock() - delete(jw.sessions, sessionID) - jw.mu.Unlock() -} - -func (jw *Jaws) FaviconURL() (s string) { - jw.mu.RLock() - s = jw.faviconURL - jw.mu.RUnlock() - return -} - -// ContentSecurityPolicy returns the generated Content-Security-Policy header value. -func (jw *Jaws) ContentSecurityPolicy() (s string) { - jw.mu.RLock() - s = jw.cspHeader - jw.mu.RUnlock() - return -} - -// SecureHeadersMiddleware wraps next with security headers that match the -// current JaWS configuration. -// -// It snapshots secureheaders.DefaultHeaders, replacing the -// Content-Security-Policy value with ContentSecurityPolicy so responses allow -// the resources configured by GenerateHeadHTML. -// -// The returned middleware does not trust forwarded HTTPS headers. -// The next handler must be non-nil. -func (jw *Jaws) SecureHeadersMiddleware(next http.Handler) http.Handler { - hdrs := maps.Clone(secureheaders.DefaultHeaders) - hdrs["Content-Security-Policy"] = []string{jw.ContentSecurityPolicy()} - return secureheaders.Middleware{ - Handler: next, - Header: hdrs, - } -} - -// GenerateHeadHTML (re-)generates the HTML code that goes in the HEAD section, ensuring -// that the provided URL resources in `extra` are loaded, along with the JaWS javascript. -// If one of the resources is named "favicon", it's URL will be stored and can -// be retrieved using FaviconURL(). -// -// You only need to call this if you add your own images, scripts and stylesheets. -func (jw *Jaws) GenerateHeadHTML(extra ...string) (err error) { - var jawsurl *url.URL - if jawsurl, err = url.Parse(jw.serveJS.Name); err == nil { - var cssurl *url.URL - if cssurl, err = url.Parse(jw.serveCSS.Name); err == nil { - var urls []*url.URL - urls = append(urls, cssurl) - urls = append(urls, jawsurl) - for _, urlstr := range extra { - if u, e := url.Parse(urlstr); e == nil { - if !strings.HasSuffix(u.Path, jawsurl.Path) { - urls = append(urls, u) - } - } else { - err = errors.Join(err, e) - } - } - headPrefix, faviconURL := PreloadHTML(urls...) - headPrefix += ` maxInterval { - maintenanceInterval = maxInterval - } - if maintenanceInterval < minInterval { - maintenanceInterval = minInterval - } - - subs := map[chan Message]*Request{} - t := time.NewTicker(maintenanceInterval) - - defer func() { - t.Stop() - for ch, rq := range subs { - rq.cancel(nil) - close(ch) - } - }() - - killSub := func(msgCh chan Message) { - if _, ok := subs[msgCh]; ok { - delete(subs, msgCh) - close(msgCh) - } - } - - // it's critical that we keep the broadcast - // distribution loop running, so any Request - // that fails to process it's messages quickly - // enough must be terminated. the alternative - // would be to drop some messages, but that - // could mean nonreproducible and seemingly - // random failures in processing logic. - mustBroadcast := func(msg Message) { - for msgCh, rq := range subs { - if msg.Dest == nil || rq.wantMessage(&msg) { - select { - case msgCh <- msg: - default: - // the exception is Update messages, more will follow eventually - if msg.What != what.Update { - killSub(msgCh) - rq.cancel(fmt.Errorf("%v: broadcast channel full sending %s", rq, msg.String())) - } - } - } - } - } - - for { - select { - case <-jw.Done(): - return - case <-jw.updateTicker.C: - if jw.distributeDirt() > 0 { - mustBroadcast(Message{What: what.Update}) - } - case <-t.C: - jw.maintenance(requestTimeout) - case sub := <-jw.subCh: - if sub.msgCh != nil { - subs[sub.msgCh] = sub.rq - } - case msgCh := <-jw.unsubCh: - killSub(msgCh) - case msg, ok := <-jw.bcastCh: - if ok { - mustBroadcast(msg) - } - } - } -} - -// Serve calls ServeWithTimeout(time.Second * 10). -// It is intended to run on it's own goroutine. -// It returns when Close is called. -func (jw *Jaws) Serve() { - jw.ServeWithTimeout(time.Second * 10) -} - -func (jw *Jaws) subscribe(rq *Request, size int) chan Message { - msgCh := make(chan Message, size) - select { - case <-jw.Done(): - close(msgCh) - return nil - case jw.subCh <- subscription{msgCh: msgCh, rq: rq}: - } - return msgCh -} - -func (jw *Jaws) unsubscribe(msgCh chan Message) { - select { - case <-jw.Done(): - case jw.unsubCh <- msgCh: - } -} - -func (jw *Jaws) maintenance(requestTimeout time.Duration) { - jw.mu.Lock() - defer jw.mu.Unlock() - now := time.Now() - for _, rq := range jw.requests { - if rq.maintenance(now, requestTimeout) { - jw.recycleLocked(rq) - } - } - for k, sess := range jw.sessions { - if sess.isDead() { - delete(jw.sessions, k) - } - } -} - -func equalIP(a, b netip.Addr) bool { - return a.Compare(b) == 0 || (a.IsLoopback() && b.IsLoopback()) -} - -func parseIP(remoteAddr string) (ip netip.Addr) { - if remoteAddr != "" { - if host, _, err := net.SplitHostPort(remoteAddr); err == nil { - ip, _ = netip.ParseAddr(host) - } else { - ip, _ = netip.ParseAddr(remoteAddr) - } - } - return -} - -func requestIsSecure(hr *http.Request) (yes bool) { - yes = secureheaders.RequestIsSecure(hr, true) - return -} - -func maybePanic(err error) { - if err != nil { - panic(err) - } -} - -// 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{ - Dest: target, - What: what.Inner, - Data: string(innerHTML), - }) -} - -// 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{ - Dest: target, - What: what.SAttr, - Data: attr + "\n" + val, - }) -} - -// 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{ - Dest: target, - What: what.RAttr, - Data: attr, - }) -} - -// 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{ - Dest: target, - What: what.SClass, - Data: cls, - }) -} - -// 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{ - Dest: target, - What: what.RClass, - Data: cls, - }) -} - -// 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{ - Dest: target, - What: what.Value, - Data: val, - }) -} - -// Insert calls the Javascript 'insertBefore()' method on -// all HTML elements matching target. -// -// 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{ - Dest: target, - What: what.Insert, - Data: where + "\n" + html, - }) -} - -// Replace replaces HTML on all HTML elements matching target. -func (jw *Jaws) Replace(target any, html string) { - jw.Broadcast(Message{ - Dest: target, - What: what.Replace, - Data: html, - }) -} - -// Delete removes the HTML element(s) matching target. -func (jw *Jaws) Delete(target any) { - jw.Broadcast(Message{ - Dest: target, - What: what.Delete, - }) -} - -// Append calls the Javascript 'appendChild()' method on all HTML elements matching target. -func (jw *Jaws) Append(target any, html template.HTML) { - jw.Broadcast(Message{ - Dest: target, - What: what.Append, - Data: string(html), - }) -} - -func maybeCompactJSON(in string) (out string) { - out = in - if strings.ContainsAny(in, "\n\t") { - var b bytes.Buffer - if err := json.Compact(&b, []byte(in)); err == nil { - out = b.String() - } - } - return -} - -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{ - Dest: tag, - What: what.Call, - Data: whitespaceRemover.Replace(jsfunc) + "=" + maybeCompactJSON(jsonstr), - }) -} - -func (jw *Jaws) getRequestLocked(jawsKey uint64, hr *http.Request) (rq *Request) { - rq = jw.reqPool.Get().(*Request) - rq.JawsKey = jawsKey - rq.lastWrite = time.Now() - rq.initial = hr - rq.ctx, rq.cancelFn = context.WithCancelCause(jw.BaseContext) - if hr != nil { - rq.remoteIP = parseIP(hr.RemoteAddr) - if sess := jw.getSessionLocked(getCookieSessionsIds(hr.Header, jw.CookieName), rq.remoteIP); sess != nil { - sess.addRequest(rq) - rq.session = sess - } - } - return rq -} - -func (jw *Jaws) recycleLocked(rq *Request) { - rq.mu.Lock() - defer rq.mu.Unlock() - if rq.JawsKey != 0 { - delete(jw.requests, rq.JawsKey) - rq.clearLocked() - jw.reqPool.Put(rq) - } -} - -func (jw *Jaws) recycle(rq *Request) { - jw.mu.Lock() - defer jw.mu.Unlock() - jw.recycleLocked(rq) -} diff --git a/core/jaws_test.go b/core/jaws_test.go deleted file mode 100644 index 117bc490..00000000 --- a/core/jaws_test.go +++ /dev/null @@ -1,599 +0,0 @@ -package jaws - -import ( - "bufio" - "bytes" - "html/template" - "net/http" - "net/http/httptest" - "net/url" - "reflect" - "strings" - "sync" - "testing" - "time" - - "github.com/linkdata/jaws/secureheaders" - "github.com/linkdata/jaws/what" -) - -type testBroadcastTagGetter struct{} - -func (testBroadcastTagGetter) JawsGetTag(*Request) any { - return Tag("expanded") -} - -func TestCoverage_GenerateHeadAndConvenienceBroadcasts(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - if err := jw.GenerateHeadHTML("%zz"); err == nil { - t.Fatal("expected url parse error") - } - if err := jw.GenerateHeadHTML("/favicon.ico", "/app.js"); err != nil { - t.Fatal(err) - } - - jw.Reload() - if msg := nextBroadcast(t, jw); msg.What != what.Reload { - t.Fatalf("unexpected reload msg %#v", msg) - } - jw.Redirect("/next") - if msg := nextBroadcast(t, jw); msg.What != what.Redirect || msg.Data != "/next" { - t.Fatalf("unexpected redirect msg %#v", msg) - } - jw.Alert("info", "hello") - if msg := nextBroadcast(t, jw); msg.What != what.Alert || msg.Data != "info\nhello" { - t.Fatalf("unexpected alert msg %#v", msg) - } - - jw.SetInner("t", template.HTML("x")) - if msg := nextBroadcast(t, jw); msg.What != what.Inner || msg.Data != "x" { - t.Fatalf("unexpected set inner msg %#v", msg) - } - jw.SetAttr("t", "k", "v") - if msg := nextBroadcast(t, jw); msg.What != what.SAttr || msg.Data != "k\nv" { - t.Fatalf("unexpected set attr msg %#v", msg) - } - jw.RemoveAttr("t", "k") - if msg := nextBroadcast(t, jw); msg.What != what.RAttr || msg.Data != "k" { - t.Fatalf("unexpected remove attr msg %#v", msg) - } - jw.SetClass("t", "c") - if msg := nextBroadcast(t, jw); msg.What != what.SClass || msg.Data != "c" { - t.Fatalf("unexpected set class msg %#v", msg) - } - jw.RemoveClass("t", "c") - if msg := nextBroadcast(t, jw); msg.What != what.RClass || msg.Data != "c" { - t.Fatalf("unexpected remove class msg %#v", msg) - } - jw.SetValue("t", "v") - if msg := nextBroadcast(t, jw); msg.What != what.Value || msg.Data != "v" { - t.Fatalf("unexpected set value msg %#v", msg) - } - jw.Insert("t", "0", "a") - if msg := nextBroadcast(t, jw); msg.What != what.Insert || msg.Data != "0\na" { - t.Fatalf("unexpected insert msg %#v", msg) - } - jw.Replace("t", "b") - if msg := nextBroadcast(t, jw); msg.What != what.Replace || msg.Data != "b" { - t.Fatalf("unexpected replace msg %#v", msg) - } - jw.Delete("t") - if msg := nextBroadcast(t, jw); msg.What != what.Delete { - t.Fatalf("unexpected delete msg %#v", msg) - } - jw.Append("t", "c") - if msg := nextBroadcast(t, jw); msg.What != what.Append || msg.Data != "c" { - t.Fatalf("unexpected append msg %#v", msg) - } - jw.JsCall("t", "fn", `{"a":1}`) - if msg := nextBroadcast(t, jw); msg.What != what.Call || msg.Data != `fn={"a":1}` { - t.Fatalf("unexpected jscall msg %#v", msg) - } -} - -func TestBroadcast_ExpandsTagDestBeforeQueue(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - tagger := testBroadcastTagGetter{} - - jw.Broadcast(Message{ - Dest: tagger, - What: what.Inner, - Data: "x", - }) - msg := nextBroadcast(t, jw) - if msg.What != what.Inner || msg.Data != "x" { - t.Fatalf("unexpected msg %#v", msg) - } - if got, ok := msg.Dest.(Tag); !ok || got != Tag("expanded") { - t.Fatalf("expected expanded Tag destination, got %T(%#v)", msg.Dest, msg.Dest) - } - - jw.Broadcast(Message{ - Dest: []any{tagger, Tag("extra")}, - What: what.Value, - Data: "v", - }) - msg = nextBroadcast(t, jw) - if msg.What != what.Value || msg.Data != "v" { - t.Fatalf("unexpected msg %#v", msg) - } - dest, ok := msg.Dest.([]any) - 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") { - t.Fatalf("unexpected expanded destination %#v", dest) - } - - jw.Broadcast(Message{ - Dest: "html-id", - What: what.Delete, - }) - msg = nextBroadcast(t, jw) - if got, ok := msg.Dest.(string); !ok || got != "html-id" { - t.Fatalf("expected raw html-id destination, got %T(%#v)", msg.Dest, msg.Dest) - } -} - -func TestBroadcast_NoneDestination(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - jw.Broadcast(Message{ - Dest: []any{}, - What: what.Update, - Data: "x", - }) - - select { - case msg := <-jw.bcastCh: - t.Fatalf("expected no pending broadcast, got %T(%#v)", msg.Dest, msg.Dest) - default: - } -} - -func TestBroadcast_ReturnsWhenClosedAndQueueFull(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - jw.Broadcast(Message{What: what.Alert, Data: "info\nfirst"}) - jw.Close() - - done := make(chan struct{}) - go func() { - jw.Broadcast(Message{What: what.Alert, Data: "info\nsecond"}) - close(done) - }() - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("broadcast blocked after close") - } - - msg := nextBroadcast(t, jw) - if msg.Data != "info\nfirst" { - t.Fatalf("unexpected queued message %#v", msg) - } - select { - case extra := <-jw.bcastCh: - t.Fatalf("unexpected extra message after close %#v", extra) - default: - } -} - -func mustParseURL(t *testing.T, raw string) *url.URL { - t.Helper() - u, err := url.Parse(raw) - if err != nil { - t.Fatalf("parse %q: %v", raw, err) - } - return u -} - -func TestJaws_GenerateHeadHTML_StoresCSPBuiltBySecureHeaders(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - extras := []string{ - "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css", - "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.min.js", - "https://images.example.com/logo.png", - } - if err = jw.GenerateHeadHTML(extras...); err != nil { - t.Fatal(err) - } - - urls := []*url.URL{ - mustParseURL(t, jw.serveCSS.Name), - mustParseURL(t, jw.serveJS.Name), - } - for _, extra := range extras { - urls = append(urls, mustParseURL(t, extra)) - } - - wantCSP, err := secureheaders.BuildContentSecurityPolicy(urls) - if err != nil { - t.Fatal(err) - } - if got := jw.ContentSecurityPolicy(); got != wantCSP { - t.Fatalf("unexpected CSP:\nwant: %q\ngot: %q", wantCSP, got) - } -} - -func TestJaws_GenerateHeadHTML_PropagatesResourceParseErrors(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - err = jw.GenerateHeadHTML("https://bad host") - if err == nil { - t.Fatal("expected parse error for extra resource URL") - } - if !strings.Contains(err.Error(), "invalid character") { - t.Fatalf("expected parse error, got: %v", err) - } -} - -func TestJaws_SecureHeadersMiddleware_UsesJawsCSP(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - if err = jw.GenerateHeadHTML( - "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css", - "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.min.js", - ); err != nil { - t.Fatal(err) - } - wantCSP := jw.ContentSecurityPolicy() - - nextCalled := false - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - nextCalled = true - w.WriteHeader(http.StatusNoContent) - }) - - req := httptest.NewRequest(http.MethodGet, "https://example.test/", nil) - rr := httptest.NewRecorder() - jw.SecureHeadersMiddleware(next).ServeHTTP(rr, req) - - if !nextCalled { - t.Fatal("expected wrapped handler to be called") - } - if got := rr.Result().StatusCode; got != http.StatusNoContent { - t.Fatalf("expected status %d, got %d", http.StatusNoContent, got) - } - - hdr := rr.Result().Header - if got := hdr.Get("Content-Security-Policy"); got != wantCSP { - t.Fatalf("expected CSP %q, got %q", wantCSP, got) - } - if got := hdr.Get("Strict-Transport-Security"); got != secureheaders.DefaultHeaders.Get("Strict-Transport-Security") { - t.Fatalf("expected HSTS %q, got %q", secureheaders.DefaultHeaders.Get("Strict-Transport-Security"), got) - } -} - -func TestJaws_SecureHeadersMiddleware_ClonesDefaultHeaders(t *testing.T) { - orig := secureheaders.DefaultHeaders.Clone() - defer func() { - secureheaders.DefaultHeaders = orig - }() - - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - wantCSP := jw.ContentSecurityPolicy() - mw := jw.SecureHeadersMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - - secureheaders.DefaultHeaders.Set("X-Frame-Options", "SAMEORIGIN") - secureheaders.DefaultHeaders.Set("Content-Security-Policy", "default-src 'none'") - - rr := httptest.NewRecorder() - mw.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.test/", nil)) - hdr := rr.Result().Header - - if got := hdr.Get("X-Frame-Options"); got != orig.Get("X-Frame-Options") { - t.Fatalf("expected X-Frame-Options %q, got %q", orig.Get("X-Frame-Options"), got) - } - if got := hdr.Get("Content-Security-Policy"); got != wantCSP { - t.Fatalf("expected CSP %q, got %q", wantCSP, got) - } -} - -func TestJaws_SecureHeadersMiddleware_DoesNotTrustForwardedHeaders(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - mw := jw.SecureHeadersMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - - req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) - req.Header.Set("X-Forwarded-Proto", "https") - rr := httptest.NewRecorder() - mw.ServeHTTP(rr, req) - - if got := rr.Result().Header.Get("Strict-Transport-Security"); got != "" { - t.Fatalf("expected no HSTS over HTTP request with forwarded proto, got %q", got) - } -} - -func TestJaws_distributeDirt_AscendingOrder(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - 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.dirtOrder = 5 - jw.mu.Unlock() - - if got, want := jw.distributeDirt(), 5; got != want { - t.Fatalf("distributeDirt() = %d, want %d", got, want) - } - - rq.mu.RLock() - got := append([]any(nil), rq.todoDirt...) - rq.mu.RUnlock() - - want := []any{ - Tag("first"), - Tag("second"), - Tag("third"), - Tag("fourth"), - Tag("fifth"), - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("dirty tags = %#v, want %#v", got, want) - } -} - -func TestJaws_GenerateHeadHTMLConcurrentWithHeadHTML(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - stop := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(2) - - go func() { - defer wg.Done() - for { - select { - case <-stop: - return - default: - if err := jw.GenerateHeadHTML("/a.js", "/b.css"); err != nil { - t.Error(err) - return - } - } - } - }() - - go func() { - defer wg.Done() - for { - select { - case <-stop: - return - default: - rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) - var buf bytes.Buffer - if err := rq.HeadHTML(&buf); err != nil { - t.Error(err) - } - jw.recycle(rq) - } - } - }() - - time.Sleep(50 * time.Millisecond) - close(stop) - wg.Wait() -} - -func TestCoverage_IDAndLookupHelpers(t *testing.T) { - NextJid = 0 - if a, b := NextID(), NextID(); b <= a { - t.Fatalf("expected increasing ids, got %d then %d", a, b) - } - if got := string(AppendID([]byte("x"))); !strings.HasPrefix(got, "x") || len(got) <= 1 { - t.Fatalf("unexpected append id result %q", got) - } - if got := MakeID(); !strings.HasPrefix(got, "jaws.") { - t.Fatalf("unexpected id %q", got) - } - - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - tmpl := template.Must(template.New("it").Parse(`ok`)) - jw.AddTemplateLookuper(tmpl) - if got := jw.LookupTemplate("it"); got == nil { - t.Fatal("expected found template") - } - if got := jw.LookupTemplate("missing"); got != nil { - t.Fatal("expected missing template") - } - jw.RemoveTemplateLookuper(nil) - jw.RemoveTemplateLookuper(tmpl) - - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) - if rq == nil { - t.Fatal("expected request") - } - if got := jw.RequestCount(); got != 1 { - t.Fatalf("expected one request, got %d", got) - } - jw.recycle(rq) - if got := jw.RequestCount(); got != 0 { - t.Fatalf("expected zero requests, got %d", got) - } -} - -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", `jaws=not-a-key`) - - ids := getCookieSessionsIds(h, "jaws") - if len(ids) != 2 || ids[0] != 11 || ids[1] != 12 { - t.Fatalf("unexpected cookie ids %#v", ids) - } - - if got := parseIP("127.0.0.1:1234"); !got.IsValid() { - t.Fatalf("expected parsed host:port ip, got %v", got) - } - if got := parseIP("::1"); !got.IsValid() { - t.Fatalf("expected parsed direct ip, got %v", got) - } - if got := parseIP(""); got.IsValid() { - t.Fatalf("expected invalid ip for empty remote addr, got %v", got) - } -} - -func TestCoverage_NonZeroRandomAndPanic(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - // First random value is zero, second is one. - zeroThenOne := append(make([]byte, 8), []byte{1, 0, 0, 0, 0, 0, 0, 0}...) - jw.kg = bufio.NewReader(bytes.NewReader(zeroThenOne)) - if got := jw.nonZeroRandomLocked(); got != 1 { - t.Fatalf("unexpected non-zero random value %d", got) - } - - defer func() { - if recover() == nil { - t.Fatal("expected panic on random source read error") - } - }() - jw.kg = bufio.NewReader(errReader{}) - _ = jw.nonZeroRandomLocked() -} - -func TestJaws_ServeWithTimeoutBounds(t *testing.T) { - // Min interval clamp path. - jwMin, err := New() - if err != nil { - t.Fatal(err) - } - doneMin := make(chan struct{}) - go func() { - jwMin.ServeWithTimeout(time.Nanosecond) - close(doneMin) - }() - jwMin.Close() - select { - case <-doneMin: - case <-time.After(time.Second): - t.Fatal("timeout waiting for ServeWithTimeout(min)") - } - - // Max interval clamp path. - jwMax, err := New() - if err != nil { - t.Fatal(err) - } - doneMax := make(chan struct{}) - go func() { - jwMax.ServeWithTimeout(10 * time.Second) - close(doneMax) - }() - jwMax.Close() - select { - case <-doneMax: - case <-time.After(time.Second): - t.Fatal("timeout waiting for ServeWithTimeout(max)") - } -} - -func TestJaws_ServeWithTimeoutFullSubscriberChannel(t *testing.T) { - jw, err := New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) - msgCh := make(chan Message) // unbuffered: always full when nobody receives - done := make(chan struct{}) - go func() { - jw.ServeWithTimeout(50 * time.Millisecond) - close(done) - }() - jw.subCh <- subscription{msgCh: msgCh, rq: rq} - // Ensure ServeWithTimeout has consumed the subscription before broadcast. - for i := 0; i <= cap(jw.subCh); i++ { - jw.subCh <- subscription{} - } - jw.bcastCh <- Message{What: what.Alert, Data: "x"} - - waitUntil := time.Now().Add(time.Second) - closed := false - for !closed && time.Now().Before(waitUntil) { - select { - case _, ok := <-msgCh: - closed = !ok - default: - time.Sleep(time.Millisecond) - } - } - if !closed { - t.Fatal("expected subscriber channel to be closed when full") - } - - jw.Close() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("timeout waiting for ServeWithTimeout exit") - } -} diff --git a/core/logger.go b/core/logger.go deleted file mode 100644 index 36f5e4f5..00000000 --- a/core/logger.go +++ /dev/null @@ -1,8 +0,0 @@ -package jaws - -// Logger matches the log/slog.Logger interface. -type Logger interface { - Info(msg string, args ...any) - Warn(msg string, args ...any) - Error(msg string, args ...any) -} diff --git a/core/namedbool_test.go b/core/namedbool_test.go deleted file mode 100644 index 84fd152c..00000000 --- a/core/namedbool_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package jaws - -import ( - "html/template" - "testing" -) - -func TestNamedBool(t *testing.T) { - is := newTestHelper(t) - - nba := NewNamedBoolArray(false) - nba.Add("1", "one") - nb := nba.data[0] - - rq := newTestRequest(t) - e := rq.NewElement(&testUi{}) - defer rq.Close() - - is.Equal(nba, nb.Array()) - is.Equal("1", nb.Name()) - is.Equal(template.HTML("one"), nb.HTML()) - - is.Equal(nb.HTML(), nb.JawsGetHTML(nil)) - - is.NoErr(nb.JawsSet(e, true)) - is.True(nb.Checked()) - is.Equal(nb.Checked(), nb.JawsGet(nil)) - is.Equal(nb.JawsSet(e, true), ErrValueUnchanged) -} diff --git a/core/namedboolarray_test.go b/core/namedboolarray_test.go deleted file mode 100644 index 55eef40c..00000000 --- a/core/namedboolarray_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package jaws - -import ( - "html/template" - "sort" - "strings" - "testing" -) - -func Test_NamedBoolArray(t *testing.T) { - is := newTestHelper(t) - nba := NewNamedBoolArray(false) - is.Equal(len(nba.data), 0) - - nba.Add("1", "one") - is.Equal(len(nba.data), 1) - is.Equal((nba.data)[0].Name(), "1") - is.Equal((nba.data)[0].HTML(), template.HTML("one")) - is.Equal((nba.data)[0].Checked(), false) - is.Equal(nba.String(), `&NamedBoolArray{[&{"1","one",false}]}`) - is.Equal(nba.Get(), "") - is.Equal(nba.IsChecked("1"), false) - is.Equal(nba.IsChecked("2"), false) - - nba.Set("1", true) - is.Equal((nba.data)[0].Name(), "1") - is.Equal((nba.data)[0].HTML(), template.HTML("one")) - is.Equal((nba.data)[0].Checked(), true) - is.Equal(nba.Get(), "1") - is.Equal(nba.IsChecked("1"), true) - is.Equal(nba.IsChecked("2"), false) - - nba.Add("2", "two") - nba.Add("2", "also two") - is.Equal(len(nba.data), 3) - is.Equal((nba.data)[0].Name(), "1") - is.Equal((nba.data)[0].HTML(), template.HTML("one")) - is.Equal((nba.data)[0].Checked(), true) - is.Equal((nba.data)[1].Name(), "2") - is.Equal((nba.data)[1].HTML(), template.HTML("two")) - is.Equal((nba.data)[1].Checked(), false) - is.Equal((nba.data)[2].Name(), "2") - is.Equal((nba.data)[2].HTML(), template.HTML("also two")) - is.Equal((nba.data)[2].Checked(), false) - is.Equal(nba.String(), `&NamedBoolArray{[&{"1","one",true},&{"2","two",false},&{"2","also two",false}]}`) - - nba.WriteLocked(func(nba []*NamedBool) []*NamedBool { - sort.Slice(nba, func(i, j int) bool { - return nba[i].Name() > nba[j].Name() - }) - return nba - }) - - nba.ReadLocked(func(nba []*NamedBool) { - is.Equal(nba[0].Name(), "2") - is.Equal(nba[1].Name(), "2") - is.Equal(nba[2].Name(), "1") - }) - - is.Equal(nba.Count("1"), 1) - is.Equal(nba.Count("2"), 2) - is.Equal(nba.Count("3"), 0) - - is.Equal((nba.data)[0].Checked(), false) - is.Equal((nba.data)[1].Checked(), false) - is.Equal((nba.data)[2].Checked(), true) - - nbaMulti := NewNamedBoolArray(true) - nbaMulti.Add("1", "one") - nbaMulti.Add("2", "two") - nbaMulti.Add("2", "also two") - nbaMulti.WriteLocked(func(nba []*NamedBool) []*NamedBool { - sort.Slice(nba, func(i, j int) bool { - return nba[i].Name() > nba[j].Name() - }) - return nba - }) - nbaMulti.Set("1", true) - nbaMulti.Set("2", true) - is.Equal((nbaMulti.data)[0].Checked(), true) - is.Equal((nbaMulti.data)[1].Checked(), true) - is.Equal((nbaMulti.data)[2].Checked(), true) - is.Equal(nbaMulti.Get(), "2") - - nba.Set("1", true) - is.Equal((nba.data)[0].Checked(), false) - is.Equal((nba.data)[1].Checked(), false) - is.Equal((nba.data)[2].Checked(), true) - is.Equal(nba.Get(), "1") - - is.Equal(nba.IsChecked("2"), false) - (nba.data)[1].Set(true) - is.Equal(nba.IsChecked("2"), true) - - rq := newTestRequest(t) - e := rq.NewElement(&testUi{}) - defer rq.Close() - - is.Equal(nba.JawsGet(e), "2") - is.NoErr(nba.JawsSet(e, "1")) - is.Equal(nba.JawsGet(e), "1") - is.Equal(nba.JawsSet(e, "1"), ErrValueUnchanged) -} - -func TestNamedBoolOption_RenderAndUpdateBranches(t *testing.T) { - NextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - nba := NewNamedBoolArray(false).Add("1", "one") - nba.Set("1", true) - contents := nba.JawsContains(nil) - if len(contents) != 1 { - t.Fatalf("want 1 content got %d", len(contents)) - } - elem := rq.NewElement(contents[0]) - var sb strings.Builder - if err := elem.JawsRender(&sb, []any{"hidden"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(sb.String(), "selected") { - t.Fatal("expected selected option rendering") - } - contents[0].JawsUpdate(elem) - nba.Set("1", false) - contents[0].JawsUpdate(elem) -} diff --git a/core/renderer.go b/core/renderer.go deleted file mode 100644 index 22a242b6..00000000 --- a/core/renderer.go +++ /dev/null @@ -1,9 +0,0 @@ -package jaws - -import "io" - -type Renderer interface { - // JawsRender is called once per Element when rendering the initial webpage. - // Do not call this yourself unless it's from within another JawsRender implementation. - JawsRender(e *Element, w io.Writer, params []any) error -} diff --git a/core/selecthandler.go b/core/selecthandler.go deleted file mode 100644 index 6e72def7..00000000 --- a/core/selecthandler.go +++ /dev/null @@ -1,6 +0,0 @@ -package jaws - -type SelectHandler interface { - Container - Setter[string] -} diff --git a/core/servehttp.go b/core/servehttp.go deleted file mode 100644 index 13ecc38f..00000000 --- a/core/servehttp.go +++ /dev/null @@ -1,54 +0,0 @@ -package jaws - -import ( - "net/http" - "strings" -) - -var headerCacheControlNoStore = []string{"no-store"} - -// ServeHTTP can handle the required JaWS endpoints, which all start with "/jaws/". -func (jw *Jaws) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - if len(r.URL.Path) > 6 && strings.HasPrefix(r.URL.Path, "/jaws/") { - if r.URL.Path[6] == '.' { - switch r.URL.Path { - case jw.serveCSS.Name: - jw.serveCSS.ServeHTTP(w, r) - return - case jw.serveJS.Name: - jw.serveJS.ServeHTTP(w, r) - return - case "/jaws/.ping": - w.Header()["Cache-Control"] = headerCacheControlNoStore - select { - case <-jw.Done(): - w.WriteHeader(http.StatusServiceUnavailable) - default: - w.WriteHeader(http.StatusNoContent) - } - return - default: - if jawsKeyString, ok := strings.CutPrefix(r.URL.Path, "/jaws/.tail/"); ok { - jawsKey := JawsKeyValue(jawsKeyString) - jw.mu.RLock() - rq := jw.requests[jawsKey] - jw.mu.RUnlock() - if rq != nil { - if err := rq.writeTailScriptResponse(w); err != nil { - rq.cancel(err) - } - return - } - } - } - } else if rq := jw.UseRequest(JawsKeyValue(r.URL.Path[6:]), r); rq != nil { - rq.ServeHTTP(w, r) - return - } - } - w.WriteHeader(http.StatusNotFound) -} diff --git a/core/servehttp_test.go b/core/servehttp_test.go deleted file mode 100644 index d0f95080..00000000 --- a/core/servehttp_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package jaws - -import ( - "compress/gzip" - "errors" - "mime" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/linkdata/jaws/staticserve" -) - -var headerContentGZip = []string{"gzip"} - -type errResponseWriter struct { - code int - header http.Header - writeErr error - writeCall int -} - -func (w *errResponseWriter) Header() http.Header { - if w.header == nil { - w.header = make(http.Header) - } - return w.header -} - -func (w *errResponseWriter) WriteHeader(statusCode int) { - w.code = statusCode -} - -func (w *errResponseWriter) Write(p []byte) (int, error) { - w.writeCall++ - return 0, w.writeErr -} - -func TestServeHTTP_GetJavascript(t *testing.T) { - jw, _ := New() - go jw.Serve() - defer jw.Close() - - is := newTestHelper(t) - - mux := http.NewServeMux() - mux.Handle("GET /jaws/", jw) - - req := httptest.NewRequest("", jw.serveJS.Name, nil) - req.Header.Add("Accept-Encoding", "blepp") - w := httptest.NewRecorder() - - mux.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusOK) - is.Equal(w.Body.Len(), len(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) - - req = httptest.NewRequest("", jw.serveJS.Name, nil) - req.Header.Add("Accept-Encoding", "gzip") - w = httptest.NewRecorder() - - mux.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusOK) - is.Equal(w.Header()["Cache-Control"], staticserve.HeaderCacheControl) - is.Equal(w.Header()["Content-Type"], []string{mime.TypeByExtension(".js")}) - is.Equal(w.Header()["Content-Encoding"], headerContentGZip) - - gr, err := gzip.NewReader(w.Body) - is.NoErr(err) - b := make([]byte, len(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)) -} - -func TestServeHTTP_GetCSS(t *testing.T) { - jw, _ := New() - go jw.Serve() - defer jw.Close() - - is := newTestHelper(t) - - mux := http.NewServeMux() - mux.Handle("GET /jaws/", jw) - - req := httptest.NewRequest("", jw.serveCSS.Name, nil) - w := httptest.NewRecorder() - - mux.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusOK) - is.Equal(w.Body.Len(), len(JawsCSS)) - is.Equal(w.Header()["Cache-Control"], staticserve.HeaderCacheControl) - is.Equal(w.Header()["Content-Type"], []string{mime.TypeByExtension(".css")}) -} - -func TestServeHTTP_GetPing(t *testing.T) { - is := newTestHelper(t) - jw, _ := New() - go jw.Serve() - defer jw.Close() - - req := httptest.NewRequest("", "/jaws/.ping", nil) - w := httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) - is.Equal(len(w.Body.Bytes()), 0) - is.Equal(w.Header()["Content-Length"], nil) - is.Equal(w.Code, http.StatusNoContent) - - req = httptest.NewRequest(http.MethodPost, "/jaws/.ping", nil) - w = httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusMethodNotAllowed) - is.Equal(w.Header()["Cache-Control"], nil) - - req = httptest.NewRequest("", "/jaws/.pong", nil) - w = httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusNotFound) - is.Equal(w.Header()["Cache-Control"], nil) - - req = httptest.NewRequest("", "/something_else", nil) - w = httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusNotFound) - is.Equal(w.Header()["Cache-Control"], nil) - - jw.Close() - - req = httptest.NewRequest("", "/jaws/.ping", nil) - w = httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusServiceUnavailable) - is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) -} - -func TestServeHTTP_GetKey(t *testing.T) { - is := newTestHelper(t) - jw, _ := New() - go jw.Serve() - defer jw.Close() - - req := httptest.NewRequest("", "/jaws/", nil) - w := httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusNotFound) - is.Equal(w.Header()["Cache-Control"], nil) - - req = httptest.NewRequest("", "/jaws/12345678", nil) - w = httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusNotFound) - is.Equal(w.Header()["Cache-Control"], nil) - - w = httptest.NewRecorder() - rq := jw.NewRequest(req) - req = httptest.NewRequest("", "/jaws/"+rq.JawsKeyString(), nil) - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusUpgradeRequired) - is.Equal(w.Header()["Cache-Control"], nil) -} - -func TestServeHTTP_Noscript(t *testing.T) { - is := newTestHelper(t) - jw, _ := New() - go jw.Serve() - defer jw.Close() - - w := httptest.NewRecorder() - rq := jw.NewRequest(httptest.NewRequest("", "/", nil)) - req := httptest.NewRequest("", "/jaws/"+rq.JawsKeyString()+"/noscript", nil) - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusNoContent) -} - -func TestServeHTTP_TailScript(t *testing.T) { - is := newTestHelper(t) - NextJid = 0 - jw, _ := New() - go jw.Serve() - defer jw.Close() - - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) - item := &testUi{} - e := rq.NewElement(item) - e.SetAttr("title", ``) - e.SetClass("cls") - e.SetInner("kept") - - req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) - req.RemoteAddr = hr.RemoteAddr - w := httptest.NewRecorder() - jw.ServeHTTP(w, req) - - is.Equal(w.Code, http.StatusOK) - is.Equal(w.Header()["Content-Type"], headerContentTypeJavaScript) - is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) - is.Equal(strings.Contains(w.Body.String(), `setAttribute("title","\x3c/script>\x3cimg onerror=alert(1) src=x>");`), true) - is.Equal(strings.Contains(w.Body.String(), `classList?.add("cls");`), true) - is.Equal(strings.Contains(w.Body.String(), "kept"), false) - is.Equal(jw.RequestCount(), 1) -} - -func TestServeHTTP_TailScript_EndpointIsPerRequest(t *testing.T) { - is := newTestHelper(t) - jw, _ := New() - go jw.Serve() - defer jw.Close() - - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) - - req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) - req.RemoteAddr = hr.RemoteAddr - w := httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusOK) - - req = httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) - req.RemoteAddr = hr.RemoteAddr - w = httptest.NewRecorder() - jw.ServeHTTP(w, req) - is.Equal(w.Code, http.StatusNoContent) -} - -func TestServeHTTP_TailScript_WriteError(t *testing.T) { - is := newTestHelper(t) - jw, _ := New() - go jw.Serve() - defer jw.Close() - - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) - item := &testUi{} - rq.NewElement(item).SetClass("cls") - - req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) - req.RemoteAddr = hr.RemoteAddr - w := &errResponseWriter{writeErr: errors.New("write failed")} - jw.ServeHTTP(w, req) - - is.Equal(w.writeCall > 0, true) - is.Equal(w.Header()["Content-Type"], headerContentTypeJavaScript) - is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) - is.Equal(jw.RequestCount(), 1) -} diff --git a/core/sessioner.go b/core/sessioner.go deleted file mode 100644 index 09b9d065..00000000 --- a/core/sessioner.go +++ /dev/null @@ -1,20 +0,0 @@ -package jaws - -import "net/http" - -type sessioner struct { - jw *Jaws - h http.Handler -} - -func (sess sessioner) ServeHTTP(wr http.ResponseWriter, r *http.Request) { - if sess.jw.GetSession(r) == nil { - sess.jw.newSession(wr, r) - } - sess.h.ServeHTTP(wr, r) -} - -// Session returns a http.Handler that ensures a JaWS Session exists before invoking h. -func (jw *Jaws) Session(h http.Handler) http.Handler { - return sessioner{jw: jw, h: h} -} diff --git a/core/sessioner_test.go b/core/sessioner_test.go deleted file mode 100644 index 0ab9a855..00000000 --- a/core/sessioner_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package jaws - -import ( - "bytes" - "net/http/httptest" - "testing" -) - -func TestJaws_Session(t *testing.T) { - NextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - dot := Tag("123") - - h := rq.Jaws.Session(rq.Jaws.Handler("testtemplate", dot)) - var buf bytes.Buffer - var rr httptest.ResponseRecorder - rr.Body = &buf - r := httptest.NewRequest("GET", "/", nil) - - if sess := rq.Jaws.GetSession(r); sess != nil { - t.Error("session already exists") - } - - h.ServeHTTP(&rr, r) - if got := buf.String(); got != `
123
` { - t.Error(got) - } - - sess := rq.Jaws.GetSession(r) - if sess == nil { - t.Error("no session") - } -} diff --git a/core/stringgetterfunc.go b/core/stringgetterfunc.go deleted file mode 100644 index 86ef5039..00000000 --- a/core/stringgetterfunc.go +++ /dev/null @@ -1,19 +0,0 @@ -package jaws - -type stringGetterFunc struct { - fn func(*Element) string - tags []any -} - -func (g *stringGetterFunc) JawsGet(e *Element) string { - return g.fn(e) -} - -func (g *stringGetterFunc) JawsGetTag(e *Request) any { - return g.tags -} - -// StringGetterFunc wraps a function and returns a Getter[string] -func StringGetterFunc(fn func(elem *Element) (s string), tags ...any) Getter[string] { - return &stringGetterFunc{fn: fn, tags: tags} -} diff --git a/core/stringgetterfunc_test.go b/core/stringgetterfunc_test.go deleted file mode 100644 index 3df2e039..00000000 --- a/core/stringgetterfunc_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package jaws - -import ( - "reflect" - "testing" -) - -func TestStringGetterFunc(t *testing.T) { - tt := &testSelfTagger{} - sg := StringGetterFunc(func(e *Element) string { - return "foo" - }, tt) - if s := sg.JawsGet(nil); s != "foo" { - t.Error(s) - } - if tags := MustTagExpand(nil, sg); !reflect.DeepEqual(tags, []any{tt}) { - t.Error(tags) - } -} diff --git a/core/subscription.go b/core/subscription.go deleted file mode 100644 index 6cfcef67..00000000 --- a/core/subscription.go +++ /dev/null @@ -1,6 +0,0 @@ -package jaws - -type subscription struct { - msgCh chan Message - rq *Request -} diff --git a/core/taggetter.go b/core/taggetter.go deleted file mode 100644 index 68a75630..00000000 --- a/core/taggetter.go +++ /dev/null @@ -1,5 +0,0 @@ -package jaws - -type TagGetter interface { - JawsGetTag(rq *Request) any // Note that the Request may be nil -} diff --git a/core/templatelookuper.go b/core/templatelookuper.go deleted file mode 100644 index fd4ae144..00000000 --- a/core/templatelookuper.go +++ /dev/null @@ -1,8 +0,0 @@ -package jaws - -import "html/template" - -// TemplateLookuper resolves a name to a *template.Template. -type TemplateLookuper interface { - Lookup(name string) *template.Template -} diff --git a/core/testhelper_test.go b/core/testhelper_test.go deleted file mode 100644 index 3b1746b0..00000000 --- a/core/testhelper_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package jaws - -import ( - "bytes" - "fmt" - "reflect" - "regexp" - "runtime" - "sort" - "testing" - "time" - - "github.com/linkdata/deadlock" -) - -func printGoroutineOrigins(t *testing.T) { - t.Helper() - buf := make([]byte, 1<<20) - n := runtime.Stack(buf, true) - buf = buf[:n] - - lines := bytes.Split(buf, []byte("\n")) - re := regexp.MustCompile(`\t(.*?):(\d+) \+0x`) - counts := make(map[string]int) - - for _, line := range lines { - m := re.FindSubmatch(line) - if len(m) == 3 { - loc := fmt.Sprintf("%s:%s", m[1], m[2]) - counts[loc]++ - } - } - - // Convert to slice for sorting - type pair struct { - loc string - count int - } - var items []pair - for k, v := range counts { - if v > 1 { // omit entries with only one goroutine - items = append(items, pair{k, v}) - } - } - - sort.Slice(items, func(i, j int) bool { - return items[i].count > items[j].count - }) - - for _, item := range items { - t.Logf("%-50s %4d goroutines\n", item.loc, item.count) - } -} - -type testHelper struct { - *time.Timer - *testing.T -} - -func newTestHelper(t *testing.T) (th *testHelper) { - seconds := 3 - if deadlock.Debug { - seconds *= 10 - } - th = &testHelper{ - T: t, - Timer: time.NewTimer(time.Second * time.Duration(seconds)), - } - t.Cleanup(th.Cleanup) - return -} - -func (th *testHelper) Cleanup() { - th.Timer.Stop() -} - -func (th *testHelper) Equal(got, want any) { - if !testEqual(got, want) { - th.Helper() - th.Errorf("\n got %T(%#v)\nwant %T(%#v)\n", got, got, want, want) - } -} - -func (th *testHelper) True(a bool) { - if !a { - th.Helper() - th.Error("not true") - } -} - -func (th *testHelper) NoErr(err error) { - if err != nil { - th.Helper() - th.Error(err) - } -} - -func (th *testHelper) Timeout() { - th.Helper() - printGoroutineOrigins(th.T) - th.Fatalf("timeout") -} - -func Test_testHelper(t *testing.T) { - mustEqual := func(a, b any) { - if !testEqual(a, b) { - t.Helper() - t.Errorf("%#v != %#v", a, b) - } - } - - mustNotEqual := func(a, b any) { - if testEqual(a, b) { - t.Helper() - t.Errorf("%#v == %#v", a, b) - } - } - - mustEqual(1, 1) - mustEqual(nil, nil) - mustEqual(nil, (*testHelper)(nil)) - mustNotEqual(1, nil) - mustNotEqual(nil, 1) - mustNotEqual((*testing.T)(nil), 1) - mustNotEqual(1, 2) - mustNotEqual((*testing.T)(nil), (*testHelper)(nil)) - mustNotEqual(int(1), int32(1)) -} - -func testNil(object any) (bool, reflect.Type) { - if object == nil { - return true, nil - } - value := reflect.ValueOf(object) - kind := value.Kind() - return kind >= reflect.Chan && kind <= reflect.Slice && value.IsNil(), value.Type() -} - -func testEqual(a, b any) bool { - if reflect.DeepEqual(a, b) { - return true - } - aIsNil, aType := testNil(a) - bIsNil, bType := testNil(b) - if !(aIsNil && bIsNil) { - return false - } - return aType == nil || bType == nil || (aType == bType) -} diff --git a/core/testjaws_test.go b/core/testjaws_test.go deleted file mode 100644 index e03b6bda..00000000 --- a/core/testjaws_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package jaws - -import ( - "bytes" - "html/template" - "log/slog" - "net/http" - "testing" - "time" -) - -type testJaws struct { - *Jaws - testtmpl *template.Template - log bytes.Buffer -} - -func newTestJaws() (tj *testJaws) { - jw, err := New() - if err != nil { - panic(err) - } - tj = &testJaws{Jaws: jw} - tj.Jaws.Logger = slog.New(slog.NewTextHandler(&tj.log, nil)) - tj.Jaws.MakeAuth = func(r *Request) Auth { - return DefaultAuth{} - } - tj.testtmpl = template.Must(template.New("testtemplate").Parse(`{{with $.Dot}}
{{.}}
{{end}}`)) - tj.AddTemplateLookuper(tj.testtmpl) - - tj.Jaws.updateTicker = time.NewTicker(time.Millisecond) - go tj.Serve() - return -} - -func (tj *testJaws) newRequest(hr *http.Request) (tr *TestRequest) { - return NewTestRequest(tj.Jaws, hr) -} - -func newTestRequest(t *testing.T) (tr *testRequest) { - tj := newTestJaws() - if t != nil { - t.Helper() - t.Cleanup(tj.Close) - } - return newWrappedTestRequest(tj.Jaws, nil) -} diff --git a/core/testrequest_test.go b/core/testrequest_test.go deleted file mode 100644 index 66314df8..00000000 --- a/core/testrequest_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package jaws - -import "net/http" - -type testRequest struct { - *TestRequest - rw testRequestWriter -} - -func newWrappedTestRequest(jw *Jaws, hr *http.Request) *testRequest { - tr := NewTestRequest(jw, hr) - if tr == nil { - return nil - } - return &testRequest{ - TestRequest: tr, - rw: testRequestWriter{ - rq: tr.Request, - Writer: tr.ResponseRecorder, - }, - } -} - -func (tr *testRequest) UI(ui UI, params ...any) error { return tr.rw.UI(ui, params...) } -func (tr *testRequest) Initial() *http.Request { return tr.rw.Initial() } -func (tr *testRequest) HeadHTML() error { return tr.rw.HeadHTML() } -func (tr *testRequest) TailHTML() error { return tr.rw.TailHTML() } -func (tr *testRequest) Session() *Session { return tr.rw.Session() } -func (tr *testRequest) Get(key string) (val any) { return tr.rw.Get(key) } -func (tr *testRequest) Set(key string, val any) { tr.rw.Set(key, val) } -func (tr *testRequest) Register(u Updater, p ...any) Jid { return tr.rw.Register(u, p...) } -func (tr *testRequest) Template(name string, dot any, params ...any) error { - return tr.rw.Template(name, dot, params...) -} diff --git a/core/testrequestwriter_test.go b/core/testrequestwriter_test.go deleted file mode 100644 index 0733cdbc..00000000 --- a/core/testrequestwriter_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package jaws - -import ( - "bytes" - "fmt" - "html/template" - "io" - "net/http" - "strings" - "text/template/parse" - - "github.com/linkdata/deadlock" - "github.com/linkdata/jaws/what" -) - -type testRequestWriter struct { - rq *Request - io.Writer -} - -type testRegisterUI struct{ Updater } - -func (testRegisterUI) JawsRender(*Element, io.Writer, []any) error { return nil } -func (ui testRegisterUI) JawsUpdate(e *Element) { ui.Updater.JawsUpdate(e) } - -func (rw testRequestWriter) UI(ui UI, params ...any) error { - return rw.rq.NewElement(ui).JawsRender(rw, params) -} - -func (rw testRequestWriter) Write(p []byte) (n int, err error) { - rw.rq.Rendering.Store(true) - return rw.Writer.Write(p) -} - -func (rw testRequestWriter) Request() *Request { - return rw.rq -} - -func (rw testRequestWriter) Initial() *http.Request { - return rw.rq.Initial() -} - -func (rw testRequestWriter) HeadHTML() error { - return rw.rq.HeadHTML(rw) -} - -func (rw testRequestWriter) TailHTML() error { - return rw.rq.TailHTML(rw) -} - -func (rw testRequestWriter) Session() *Session { - return rw.rq.Session() -} - -func (rw testRequestWriter) Get(key string) (val any) { - return rw.rq.Get(key) -} - -func (rw testRequestWriter) Set(key string, val any) { - rw.rq.Set(key, val) -} - -func (rw testRequestWriter) Register(updater Updater, params ...any) Jid { - elem := rw.rq.NewElement(testRegisterUI{Updater: updater}) - elem.Tag(updater) - elem.ApplyParams(params) - updater.JawsUpdate(elem) - return elem.Jid() -} - -func (rq *Request) Writer(w io.Writer) testRequestWriter { - return testRequestWriter{rq: rq, Writer: w} -} - -type testHandler struct { - *Jaws - Template testTemplateUI -} - -func (h testHandler) ServeHTTP(wr http.ResponseWriter, r *http.Request) { - _ = h.Log(h.NewRequest(r).NewElement(h.Template).JawsRender(wr, nil)) -} - -func (jw *Jaws) Handler(name string, dot any) http.Handler { - return testHandler{Jaws: jw, Template: testTemplateUI{Name: name, Dot: dot}} -} - -type testWith struct { - *Element - testRequestWriter - Dot any - Attrs template.HTMLAttr - Auth Auth -} - -type testTemplateUI struct { - Name string - Dot any -} - -func (t testTemplateUI) String() string { - return fmt.Sprintf("{%q, %s}", t.Name, TagString(t.Dot)) -} - -func findJidOrJsOrHTMLNode(node parse.Node) (found bool) { - switch node := node.(type) { - case *parse.TextNode: - if node != nil { - found = found || bytes.Contains(node.Text, []byte("")) - } - case *parse.ListNode: - if node != nil { - for _, n := range node.Nodes { - found = found || findJidOrJsOrHTMLNode(n) - } - } - case *parse.ActionNode: - if node != nil { - found = findJidOrJsOrHTMLNode(node.Pipe) - } - case *parse.WithNode: - if node != nil { - found = findJidOrJsOrHTMLNode(&node.BranchNode) - } - case *parse.BranchNode: - if node != nil { - found = findJidOrJsOrHTMLNode(node.Pipe) - found = found || findJidOrJsOrHTMLNode(node.List) - found = found || findJidOrJsOrHTMLNode(node.ElseList) - } - case *parse.IfNode: - if node != nil { - found = findJidOrJsOrHTMLNode(node.Pipe) - found = found || findJidOrJsOrHTMLNode(node.List) - found = found || findJidOrJsOrHTMLNode(node.ElseList) - } - case *parse.PipeNode: - if node != nil { - for _, n := range node.Cmds { - found = found || findJidOrJsOrHTMLNode(n) - } - } - case *parse.CommandNode: - if node != nil { - for _, n := range node.Args { - found = found || findJidOrJsOrHTMLNode(n) - } - } - case *parse.VariableNode: - if node != nil { - for _, s := range node.Ident { - found = found || (s == "Jid") || (s == "JsFunc") || (s == "JsVar") - } - } - } - return -} - -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 { - e.Request.TagExpanded(e, expandedtags) - tags, handlers, attrs := ParseParams(params) - e.Tag(tags...) - e.AddHandlers(handlers...) - attrstr := template.HTMLAttr(strings.Join(attrs, " ")) // #nosec G203 - var auth Auth = DefaultAuth{} - if f := e.Request.Jaws.MakeAuth; f != nil { - auth = f(e.Request) - } - err = fmt.Errorf("missing template %q", t.Name) - if tmpl := e.Request.Jaws.LookupTemplate(t.Name); tmpl != nil { - err = tmpl.Execute(wr, testWith{ - Element: e, - testRequestWriter: testRequestWriter{rq: e.Request, Writer: wr}, - Dot: t.Dot, - Attrs: attrstr, - Auth: auth, - }) - if deadlock.Debug && e.Jaws.Logger != nil { - if !findJidOrJsOrHTMLNode(tmpl.Tree.Root) { - e.Jaws.Logger.Warn("jaws: template has no Jid reference", "template", t.Name) - } - } - } - } - return -} - -func (t testTemplateUI) JawsUpdate(e *Element) { - if dot, ok := t.Dot.(Updater); ok { - dot.JawsUpdate(e) - } -} - -func (t testTemplateUI) JawsEvent(e *Element, wht what.What, val string) error { - return CallEventHandlers(t.Dot, e, wht, val) -} - -func (rw testRequestWriter) Template(name string, dot any, params ...any) error { - return rw.UI(testTemplateUI{Name: name, Dot: dot}, params...) -} diff --git a/core/testsupport.go b/core/testsupport.go deleted file mode 100644 index 50dd5c42..00000000 --- a/core/testsupport.go +++ /dev/null @@ -1,82 +0,0 @@ -package jaws - -import ( - "bytes" - "html/template" - "net/http" - "net/http/httptest" - "strings" -) - -// TestRequest is a request harness intended for tests. -// -// Exposed for testing only. -type TestRequest struct { - *Request - *httptest.ResponseRecorder - ReadyCh chan struct{} - DoneCh chan struct{} - InCh chan WsMsg - OutCh chan WsMsg - BcastCh chan Message - ExpectPanic bool - Panicked bool - PanicVal any -} - -// NewTestRequest creates a TestRequest for use when testing. -// Passing nil for hr will create a "GET /" request with no body. -// -// Exposed for testing only. -func NewTestRequest(jw *Jaws, hr *http.Request) (tr *TestRequest) { - if hr == nil { - hr = httptest.NewRequest(http.MethodGet, "/", nil) - } - rr := httptest.NewRecorder() - rr.Body = &bytes.Buffer{} - rq := jw.NewRequest(hr) - if rq != nil && jw.UseRequest(rq.JawsKey, hr) == rq { - bcastCh := jw.subscribe(rq, 64) - for i := 0; i <= cap(jw.subCh); i++ { - jw.subCh <- subscription{} // ensure subscription is processed - } - - tr = &TestRequest{ - ReadyCh: make(chan struct{}), - DoneCh: make(chan struct{}), - InCh: make(chan WsMsg), - OutCh: make(chan WsMsg, cap(bcastCh)), - BcastCh: bcastCh, - Request: rq, - ResponseRecorder: rr, - } - - go func() { - defer func() { - if tr.ExpectPanic { - if tr.PanicVal = recover(); tr.PanicVal != nil { - tr.Panicked = true - } - } - close(tr.DoneCh) - }() - close(tr.ReadyCh) - tr.process(tr.BcastCh, tr.InCh, tr.OutCh) // unsubs from bcast, closes outCh - jw.recycle(tr.Request) - }() - } - - return -} - -func (tr *TestRequest) Close() { - close(tr.InCh) -} - -func (tr *TestRequest) BodyString() string { - return strings.TrimSpace(tr.Body.String()) -} - -func (tr *TestRequest) BodyHTML() template.HTML { - return template.HTML(tr.BodyString()) /* #nosec G203 */ -} diff --git a/core/testuiwidget_test.go b/core/testuiwidget_test.go deleted file mode 100644 index 10b4b0a4..00000000 --- a/core/testuiwidget_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package jaws - -import ( - "html/template" - "io" - - "github.com/linkdata/jaws/what" -) - -type testDivWidget struct { - inner template.HTML -} - -func (ui testDivWidget) JawsRender(e *Element, w io.Writer, params []any) error { - e.ApplyParams(params) - return WriteHTMLInner(w, e.Jid(), "div", "", ui.inner) -} - -func (testDivWidget) JawsUpdate(*Element) {} - -type testTextInputWidget struct { - Setter[string] - tag any - last string -} - -func newTestTextInputWidget(s Setter[string]) *testTextInputWidget { - return &testTextInputWidget{Setter: s} -} - -func (ui *testTextInputWidget) JawsRender(e *Element, w io.Writer, params []any) (err error) { - if ui.tag, err = e.ApplyGetter(ui.Setter); err == nil { - attrs := e.ApplyParams(params) - v := ui.JawsGet(e) - ui.last = v - err = WriteHTMLInput(w, e.Jid(), "text", v, attrs) - } - return -} - -func (ui *testTextInputWidget) JawsUpdate(e *Element) { - if v := ui.JawsGet(e); v != ui.last { - ui.last = v - e.SetValue(v) - } -} - -func (ui *testTextInputWidget) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - if changed, setErr := e.maybeDirty(ui.tag, ui.Setter.JawsSet(e, val)); setErr != nil { - err = setErr - } else { - err = nil - if changed { - ui.last = val - } - } - } - return -} diff --git a/core/ui.go b/core/ui.go deleted file mode 100644 index dec93e95..00000000 --- a/core/ui.go +++ /dev/null @@ -1,8 +0,0 @@ -package jaws - -// UI defines the required methods on JaWS UI objects. -// In addition, all UI objects must be comparable so they can be used as map keys. -type UI interface { - Renderer - Updater -} diff --git a/core/updater.go b/core/updater.go deleted file mode 100644 index e811cdc3..00000000 --- a/core/updater.go +++ /dev/null @@ -1,7 +0,0 @@ -package jaws - -type Updater interface { - // JawsUpdate is called for an Element that has been marked dirty to update it's HTML. - // Do not call this yourself unless it's from within another JawsUpdate implementation. - JawsUpdate(e *Element) -} diff --git a/core/ws.go b/core/ws.go deleted file mode 100644 index e760fbe4..00000000 --- a/core/ws.go +++ /dev/null @@ -1,136 +0,0 @@ -package jaws - -import ( - "context" - "errors" - "io" - "net/http" - "strings" - - "github.com/coder/websocket" -) - -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 WsMsg) - broadcastMsgCh := rq.Jaws.subscribe(rq, 4+len(rq.elems)*4) - outboundMsgCh := make(chan 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 - msg.FillAlert(rq.Jaws.Log(err)) - _ = ws.Write(r.Context(), websocket.MessageText, msg.Append(nil)) - } - } - 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<- 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 { - 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 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 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/ws_test.go b/core/ws_test.go deleted file mode 100644 index e90e37f6..00000000 --- a/core/ws_test.go +++ /dev/null @@ -1,689 +0,0 @@ -package jaws - -import ( - "bufio" - "bytes" - "context" - "errors" - "net" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/coder/websocket" - "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 := 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 := 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 WsMsg - m2.FillAlert(fooError) - if !bytes.Equal(b, m2.Append(nil)) { - t.Error(b) - } -} - -func TestReader_RespectsContextDone(t *testing.T) { - th := newTestHelper(t) - ts := newTestServer() - defer ts.Close() - - msg := WsMsg{Jid: Jid(1234), What: what.Input} - doneCh := make(chan struct{}) - inCh := make(chan 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 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 := 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 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 := 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 WsMsg, 2) - defer close(outCh) - - msg := 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 WsMsg, 2) - - msg := 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 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 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 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 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 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 := 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 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 := 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 -} diff --git a/core/element.go b/element.go similarity index 96% rename from core/element.go rename to element.go index 3d6034cc..70340c08 100644 --- a/core/element.go +++ b/element.go @@ -9,8 +9,10 @@ import ( "strings" "sync/atomic" - "github.com/linkdata/jaws/jid" - "github.com/linkdata/jaws/what" + "github.com/linkdata/jaws/lib/jid" + "github.com/linkdata/jaws/lib/jtag" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" ) // An Element is an instance of a *Request, an UI object and a Jid. @@ -76,7 +78,7 @@ func (e *Element) renderDebug(w io.Writer) { if i > 0 { sb.WriteString(", ") } - sb.WriteString(TagString(tag)) + sb.WriteString(jtag.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.(jtag.TagGetter); ok { tag = tagger.JawsGetTag(e.Request) } if eh, ok := getter.(EventHandler); ok { diff --git a/core/element_test.go b/element_test.go similarity index 92% rename from core/element_test.go rename to element_test.go index eba417d8..93c0a23a 100644 --- a/core/element_test.go +++ b/element_test.go @@ -12,8 +12,10 @@ import ( "time" "github.com/linkdata/deadlock" - "github.com/linkdata/jaws/jid" - "github.com/linkdata/jaws/what" + "github.com/linkdata/jaws/lib/jid" + "github.com/linkdata/jaws/lib/jtag" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" ) type testUi struct { @@ -35,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 { @@ -69,7 +70,7 @@ type testApplyGetterAll struct { initErr error } -func (a testApplyGetterAll) JawsGetTag(*Request) any { return Tag("tg") } +func (a testApplyGetterAll) JawsGetTag(jtag.Context) any { return jtag.Tag("tg") } func (a testApplyGetterAll) JawsClick(*Element, string) error { return ErrEventUnhandled } @@ -101,9 +102,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(jtag.Tag("zomg"))) + e.Tag(jtag.Tag("zomg")) + is.True(e.HasTag(jtag.Tag("zomg"))) s := e.String() if !strings.Contains(s, "zomg") { t.Error(s) @@ -128,7 +129,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, @@ -231,7 +232,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") } @@ -260,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, jtag.ErrNotComparable) th.Equal(changed, false) - th.Equal(err, ErrNotComparable) + th.Equal(err, jtag.ErrNotComparable) } func TestElement_RenderDebugAndDeletedBranches(t *testing.T) { @@ -282,7 +283,7 @@ func TestElement_RenderDebugAndDeletedBranches(t *testing.T) { elem.renderDebug(&sb) rq.mu.Unlock() - elem.Tag(Tag("a"), Tag("b")) + elem.Tag(jtag.Tag("a"), jtag.Tag("b")) sb.Reset() elem.renderDebug(&sb) if !strings.Contains(sb.String(), ", ") { @@ -314,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) + if !elem.HasTag(jtag.Tag("tg")) { + t.Fatalf("missing Tag('tg') in %#v", gotTags) } - agErr := testApplyGetterAll{initErr: ErrNotComparable} - if _, err := elem.ApplyGetter(agErr); err != ErrNotComparable { + agErr := testApplyGetterAll{initErr: jtag.ErrNotComparable} + if _, err := elem.ApplyGetter(agErr); err != jtag.ErrNotComparable { t.Fatalf("expected init err, got %v", err) } @@ -486,7 +487,7 @@ func TestElement_JawsInit(t *testing.T) { defer rq.Close() tss := &testUi{s: "foo"} - tss.initError = ErrNotComparable + tss.initError = jtag.ErrNotComparable e := rq.NewElement(tss) tag, err := e.ApplyGetter(tss) @@ -494,7 +495,7 @@ func TestElement_JawsInit(t *testing.T) { if tag != tss { t.Errorf("tag was %#v", tag) } - if err != ErrNotComparable { + if err != jtag.ErrNotComparable { t.Error(err) } } diff --git a/core/errnowebsocketrequest.go b/errnowebsocketrequest.go similarity index 100% rename from core/errnowebsocketrequest.go rename to errnowebsocketrequest.go diff --git a/core/errpendingcancelled.go b/errpendingcancelled.go similarity index 85% rename from core/errpendingcancelled.go rename to errpendingcancelled.go index 133d3e8c..2045cd1d 100644 --- a/core/errpendingcancelled.go +++ b/errpendingcancelled.go @@ -2,6 +2,8 @@ package jaws import ( "fmt" + + "github.com/linkdata/jaws/lib/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/errvalueunchanged.go b/errvalueunchanged.go similarity index 100% rename from core/errvalueunchanged.go rename to errvalueunchanged.go diff --git a/core/eventhandler.go b/eventhandler.go similarity index 98% rename from core/eventhandler.go rename to eventhandler.go index 90924535..5d29f4be 100644 --- a/core/eventhandler.go +++ b/eventhandler.go @@ -4,7 +4,7 @@ import ( "fmt" "reflect" - "github.com/linkdata/jaws/what" + "github.com/linkdata/jaws/lib/what" ) // ErrEventHandlerPanic is returned when an event handler panics. diff --git a/core/eventhandler_test.go b/eventhandler_test.go similarity index 96% rename from core/eventhandler_test.go rename to eventhandler_test.go index ec24b0a5..ed3604e2 100644 --- a/core/eventhandler_test.go +++ b/eventhandler_test.go @@ -8,7 +8,9 @@ import ( "strings" "testing" - "github.com/linkdata/jaws/what" + "github.com/linkdata/jaws/lib/jtag" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" ) type testJawsEvent struct { @@ -34,7 +36,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(jtag.Context) (tag any) { return t.tag } @@ -53,7 +55,7 @@ func (t *testJawsEvent) JawsUpdate(e *Element) { var _ ClickHandler = (*testJawsEvent)(nil) var _ EventHandler = (*testJawsEvent)(nil) -var _ TagGetter = (*testJawsEvent)(nil) +var _ jtag.TagGetter = (*testJawsEvent)(nil) var _ UI = (*testJawsEvent)(nil) func Test_JawsEvent_NonClickInvokesJawsEventForDualHandler(t *testing.T) { @@ -68,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() @@ -355,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/example_test.go b/examples/example_test.go similarity index 83% rename from example_test.go rename to examples/example_test.go index 0f44407e..086e3bcc 100644 --- a/example_test.go +++ b/examples/example_test.go @@ -1,4 +1,4 @@ -package jaws_test +package example import ( "html/template" @@ -7,7 +7,8 @@ import ( "sync" "github.com/linkdata/jaws" - "github.com/linkdata/jaws/ui" + "github.com/linkdata/jaws/lib/bind" + "github.com/linkdata/jaws/lib/ui" ) const indexhtml = ` @@ -38,6 +39,6 @@ func Example() { var mu sync.Mutex var f float64 - http.DefaultServeMux.Handle("GET /", ui.Handler(jw, "index", jaws.Bind(&mu, &f))) + http.DefaultServeMux.Handle("GET /", ui.Handler(jw, "index", bind.New(&mu, &f))) slog.Error(http.ListenAndServe("localhost:8080", nil).Error()) } diff --git a/go.mod b/go.mod index 49239b14..008494a2 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/coder/websocket v1.8.14 github.com/linkdata/deadlock v0.5.5 github.com/linkdata/jq v0.0.3 + github.com/linkdata/secureheaders v1.0.0 + github.com/linkdata/staticserve v1.0.0 ) require github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect diff --git a/go.sum b/go.sum index 61427ce6..4860fef3 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrk github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= github.com/linkdata/jq v0.0.3 h1:m1eFUh2gJ8ebg3GF5d+etiGbMtYtYSO3M7jn2Gy5cs4= github.com/linkdata/jq v0.0.3/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE= +github.com/linkdata/secureheaders v1.0.0 h1:mJ1JLlgvSsmagfmR84+fWMLW7HwEg/83htM7D5BF7+A= +github.com/linkdata/secureheaders v1.0.0/go.mod h1:50TiKmPaWki8gIZXmBxrLUinyJst12YuN3EbsXZMO+4= +github.com/linkdata/staticserve v1.0.0 h1:qv0JL04ay/sbkwxAtPig5rKPFuSwL7dPB8xGlEZAM58= +github.com/linkdata/staticserve v1.0.0/go.mod h1:uOUrHkbNEkVXWvuw7gk0ydm39r9Y1ReZA9o4aWF+hgc= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= diff --git a/internal/routepattern/normalize.go b/internal/routepattern/normalize.go deleted file mode 100644 index 559e8eb7..00000000 --- a/internal/routepattern/normalize.go +++ /dev/null @@ -1,53 +0,0 @@ -package routepattern - -import ( - "net/http" - "strings" -) - -func isMethodChar(c byte) bool { - if c >= 'A' && c <= 'Z' { - return true - } - if c >= '0' && c <= '9' { - return true - } - switch c { - case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~': - return true - } - return false -} - -func hasMethodPrefix(pattern string) bool { - if i := strings.IndexAny(pattern, " \t"); i > 0 { - method := pattern[:i] - for _, c := range []byte(method) { - if !isMethodChar(c) { - return false - } - } - return true - } - return false -} - -// EnsurePrefixSlash returns s with a leading slash. -func EnsurePrefixSlash(s string) string { - if !strings.HasPrefix(s, "/") { - s = "/" + s - } - return s -} - -// NormalizeGET returns a method-aware ServeMux pattern. -// -// If pattern already has a method prefix, it is returned unchanged. -// Otherwise GET is prepended and the path is made absolute. -func NormalizeGET(pattern string) string { - pattern = strings.TrimSpace(pattern) - if hasMethodPrefix(pattern) { - return pattern - } - return http.MethodGet + " " + EnsurePrefixSlash(pattern) -} diff --git a/internal/routepattern/normalize_test.go b/internal/routepattern/normalize_test.go deleted file mode 100644 index 06617ffd..00000000 --- a/internal/routepattern/normalize_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package routepattern - -import "testing" - -func TestHasMethodPrefix(t *testing.T) { - for _, tc := range []struct { - pattern string - want bool - }{ - {pattern: "GET /x", want: true}, - {pattern: "POST\t/x", want: true}, - {pattern: "M-SEARCH\t/x", want: true}, - {pattern: "P0ST /x", want: true}, - {pattern: "GE/T /x", want: false}, - {pattern: "get /x", want: false}, - {pattern: "/x", want: false}, - {pattern: "", want: false}, - {pattern: " ", want: false}, - } { - if got := hasMethodPrefix(tc.pattern); got != tc.want { - t.Errorf("hasMethodPrefix(%q): want %v, got %v", tc.pattern, tc.want, got) - } - } -} - -func TestIsMethodChar(t *testing.T) { - for _, tc := range []struct { - c byte - want bool - }{ - {c: 'A', want: true}, - {c: '7', want: true}, - {c: '-', want: true}, - {c: '~', want: true}, - {c: '/', want: false}, - {c: 'a', want: false}, - } { - if got := isMethodChar(tc.c); got != tc.want { - t.Errorf("isMethodChar(%q): want %v, got %v", tc.c, tc.want, got) - } - } -} - -func TestNormalizeGET(t *testing.T) { - for _, tc := range []struct { - pattern string - want string - }{ - {pattern: "/file.js", want: "GET /file.js"}, - {pattern: "file.js", want: "GET /file.js"}, - {pattern: "", want: "GET /"}, - {pattern: " file.js\t", want: "GET /file.js"}, - {pattern: "POST /file.js", want: "POST /file.js"}, - {pattern: "POST\t/file.js", want: "POST\t/file.js"}, - } { - if got := NormalizeGET(tc.pattern); got != tc.want { - t.Errorf("NormalizeGET(%q): want %q, got %q", tc.pattern, tc.want, got) - } - } -} diff --git a/jaws.go b/jaws.go index 03811ee9..30555006 100644 --- a/jaws.go +++ b/jaws.go @@ -1,533 +1,962 @@ +// package jaws provides a mechanism to create dynamic +// webpages using Javascript and WebSockets. +// +// It integrates well with Go's html/template package, +// but can be used without it. It can be used with any +// router that supports the standard ServeHTTP interface. package jaws import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "maps" + "net" + "net/http" + "net/netip" + "net/textproto" + "net/url" + "slices" + "sort" + "strconv" + "strings" "sync" + "sync/atomic" "time" - core "github.com/linkdata/jaws/core" - "github.com/linkdata/jaws/jid" - "github.com/linkdata/jaws/ui" + "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/lib/assets" + "github.com/linkdata/jaws/lib/jid" + "github.com/linkdata/jaws/lib/jtag" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" + "github.com/linkdata/secureheaders" + "github.com/linkdata/staticserve" ) -// The point of this is to not have a zillion files in the repository root -// while keeping the import path unchanged. -// -// Most exports use direct assignment to avoid wrapper overhead. -// Generic functions must be wrapped since they cannot be assigned without instantiation. - -type ( - // Jid is the identifier type used for HTML elements managed by JaWS. - // - // It is provided as a convenience alias to the value defined in the jid - // subpackage so applications do not have to import that package directly - // when working with element IDs. - Jid = jid.Jid - // Jaws holds the server-side state and configuration for a JaWS instance. - // - // A single Jaws value coordinates template lookup, session handling and the - // request lifecycle that keeps the browser and backend synchronized via - // WebSockets. The zero value is not ready for use; construct instances with - // New to ensure the helper goroutines and static assets are prepared. - Jaws = core.Jaws - // Request maintains the state for a JaWS WebSocket connection, and handles processing - // of events and broadcasts. - // - // Note that we have to store the context inside the struct because there is no call chain - // between the Request being created and it being used once the WebSocket is created. - Request = core.Request - // An Element is an instance of a *Request, an UI object and a Jid. - Element = core.Element - // UI defines the required methods on JaWS UI objects. - // In addition, all UI objects must be comparable so they can be used as map keys. - UI = core.UI - Updater = core.Updater - Renderer = core.Renderer - // TemplateLookuper resolves a name to a *template.Template. - TemplateLookuper = core.TemplateLookuper - // HandleFunc matches the signature of http.ServeMux.Handle(). - HandleFunc = core.HandleFunc - Formatter = core.Formatter - Auth = core.Auth - // InitHandler allows initializing UI getters and setters before their use. - // - // You can of course initialize them in the call from the template engine, - // but at that point you don't have access to the Element, Element.Context - // or Element.Session. - InitHandler = core.InitHandler - ClickHandler = core.ClickHandler - EventHandler = core.EventHandler - SelectHandler = core.SelectHandler - Container = core.Container - Getter[T comparable] = core.Getter[T] - Setter[T comparable] = core.Setter[T] - Binder[T comparable] = core.Binder[T] - // A HTMLGetter is the primary way to deliver generated HTML content to dynamic HTML nodes. - HTMLGetter = core.HTMLGetter - // Logger matches the log/slog.Logger interface. - Logger = core.Logger - RWLocker = core.RWLocker - TagGetter = core.TagGetter - // NamedBool stores a named boolen value with a HTML representation. - NamedBool = core.NamedBool - // NamedBoolArray stores the data required to support HTML 'select' elements - // and sets of HTML radio buttons. It it safe to use from multiple goroutines - // concurrently. - NamedBoolArray = core.NamedBoolArray - Session = core.Session - Tag = core.Tag - // TestRequest is a request harness intended for tests. - // - // Exposed for testing only. - TestRequest = core.TestRequest +const ( + DefaultUpdateInterval = time.Millisecond * 100 // Default browser update interval ) -var ( - ErrEventUnhandled = core.ErrEventUnhandled - ErrIllegalTagType = core.ErrIllegalTagType // ErrIllegalTagType is returned when a UI tag type is disallowed - ErrNotComparable = core.ErrNotComparable - ErrNotUsableAsTag = core.ErrNotUsableAsTag - ErrNoWebSocketRequest = core.ErrNoWebSocketRequest - ErrPendingCancelled = core.ErrPendingCancelled - ErrValueUnchanged = core.ErrValueUnchanged - ErrValueNotSettable = core.ErrValueNotSettable - ErrRequestAlreadyClaimed = core.ErrRequestAlreadyClaimed - ErrJavascriptDisabled = core.ErrJavascriptDisabled - ErrTooManyTags = core.ErrTooManyTags -) +type subscription struct { + msgCh chan wire.Message + rq *Request +} -const ( - // ISO8601 is the date format used by date input widgets (YYYY-MM-DD). - ISO8601 = core.ISO8601 -) +// Jid is the identifier type used for HTML elements managed by JaWS. +// +// It is provided as a convenience alias to the value defined in the jid +// subpackage so applications do not have to import that package directly +// when working with element IDs. +type Jid = jid.Jid // convenience alias + +// Jaws holds the server-side state and configuration for a JaWS instance. +// +// A single Jaws value coordinates template lookup, session handling and the +// request lifecycle that keeps the browser and backend synchronized via +// WebSockets. The zero value is not ready for use; construct instances with +// New to ensure the helper goroutines and static assets are prepared. +type Jaws struct { + CookieName string // Name for session cookies, defaults to "jaws" + Logger Logger // Optional logger to use + 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 wire.Message + subCh chan subscription + unsubCh chan chan wire.Message + updateTicker *time.Ticker + reqPool sync.Pool + serveJS *staticserve.StaticServe + serveCSS *staticserve.StaticServe + mu deadlock.RWMutex // protects following + headPrefix string + faviconURL string + cspHeader string + tmplookers []TemplateLookuper + kg *bufio.Reader + closeCh chan struct{} // closed when Close() has been called + requests map[uint64]*Request + sessions map[uint64]*Session + dirty map[any]int + dirtOrder int +} -var ( - // New allocates a JaWS instance with the default configuration. - // - // The returned Jaws value is ready for use: static assets are embedded, - // internal goroutines are configured and the request pool is primed. Call - // 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 - WriteHTMLTag = core.WriteHTMLTag - // HTMLGetterFunc wraps a function and returns a HTMLGetter. - HTMLGetterFunc = core.HTMLGetterFunc - // StringGetterFunc wraps a function and returns a Getter[string] - StringGetterFunc = core.StringGetterFunc - // MakeHTMLGetter returns a HTMLGetter for v. - // - // Depending on the type of v, we return: - // - // - HTMLGetter: `JawsGetHTML(e *Element) template.HTML` to be used as-is. - // - Getter[string]: `JawsGet(elem *Element) string` that will be escaped using `html.EscapeString`. - // - Formatter: `Format("%v") string` that will be escaped using `html.EscapeString`. - // - fmt.Stringer: `String() string` that will be escaped using `html.EscapeString`. - // - a static `template.HTML` or `string` to be used as-is with no HTML escaping. - // - everything else is rendered using `fmt.Sprint()` and escaped using `html.EscapeString`. - // - // WARNING: Plain string values are NOT HTML-escaped. This is intentional so that - // HTML markup can be passed conveniently from Go templates (e.g. `{{$.Span "text"}}`). - // Never pass untrusted user input as a plain string; use [template.HTML] to signal - // that the content is trusted, or wrap user input in a [Getter] or [fmt.Stringer] - // so it will be escaped automatically. - MakeHTMLGetter = core.MakeHTMLGetter - NewNamedBool = core.NewNamedBool - NewNamedBoolArray = core.NewNamedBoolArray - // NewTestRequest creates a TestRequest for use when testing. - // Passing nil for hr will create a "GET /" request with no body. - // - // Exposed for testing only. - NewTestRequest = core.NewTestRequest -) +// New allocates a JaWS instance with the default configuration. +// +// The returned Jaws value is ready for use: static assets are embedded, +// internal goroutines are configured and the request pool is primed. Call +// 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", 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 wire.Message, 1), + subCh: make(chan subscription, 1), + unsubCh: make(chan chan wire.Message, 1), + updateTicker: time.NewTicker(DefaultUpdateInterval), + kg: bufio.NewReader(rand.Reader), + requests: make(map[uint64]*Request), + sessions: make(map[uint64]*Session), + dirty: make(map[any]int), + closeCh: make(chan struct{}), + } + if err = tmp.GenerateHeadHTML(); err == nil { + jw = tmp + jw.reqPool.New = func() any { + return (&Request{ + Jaws: jw, + tagMap: make(map[any][]*Element), + }).clearLocked() + } + } + } + } + + return +} -// Bind returns a Binder[T] with the given sync.Locker (or RWLocker) and a pointer to the underlying value of type T. -// -// The pointer will be used as the UI tag. -func Bind[T comparable](l sync.Locker, p *T) Binder[T] { - return core.Bind(l, p) +// Close frees resources associated with the JaWS object, and +// closes the completion channel if the JaWS was created with New(). +// Once the completion channel is closed, broadcasts and sends may be discarded. +// Subsequent calls to Close() have no effect. +func (jw *Jaws) Close() { + jw.mu.Lock() + select { + case <-jw.closeCh: + // already closed + default: + close(jw.closeCh) + } + jw.updateTicker.Stop() + jw.mu.Unlock() } -/* - The following should no longer be accessed using jaws.X, - but should instead be ui.X. +// Done returns the channel that is closed when Close has been called. +func (jw *Jaws) Done() <-chan struct{} { + return jw.closeCh +} - Mark as deprecated. -*/ +// AddTemplateLookuper adds an object that can resolve +// strings to *template.Template. +func (jw *Jaws) AddTemplateLookuper(tl TemplateLookuper) (err error) { + if tl != nil { + if err = jtag.NewErrNotComparable(tl); err == nil { + jw.mu.Lock() + if !slices.Contains(jw.tmplookers, tl) { + jw.tmplookers = append(jw.tmplookers, tl) + } + jw.mu.Unlock() + } + } + return +} -// Template is an alias for ui.Template. -// -// Deprecated: use ui.Template directly. -// -//go:fix inline -type Template = ui.Template +// RemoveTemplateLookuper removes the given object from +// the list of TemplateLookupers. +func (jw *Jaws) RemoveTemplateLookuper(tl TemplateLookuper) (err error) { + if tl != nil { + if err = jtag.NewErrNotComparable(tl); err == nil { + jw.mu.Lock() + jw.tmplookers = slices.DeleteFunc(jw.tmplookers, func(x TemplateLookuper) bool { return x == tl }) + jw.mu.Unlock() + } + } + return +} -// RequestWriter is an alias for ui.RequestWriter. -// -// Deprecated: use ui.RequestWriter directly. -// -//go:fix inline -type RequestWriter = ui.RequestWriter +// LookupTemplate queries the known TemplateLookupers in the order +// they were added and returns the first found. +func (jw *Jaws) LookupTemplate(name string) *template.Template { + jw.mu.RLock() + defer jw.mu.RUnlock() + for _, tl := range jw.tmplookers { + if t := tl.Lookup(name); t != nil { + return t + } + } + return nil +} -// PathSetter is an alias for ui.PathSetter. -// -// Deprecated: use ui.PathSetter directly. +// RequestCount returns the number of Requests. // -//go:fix inline -type PathSetter = ui.PathSetter +// The count includes all Requests, including those being rendered, +// those waiting for the WebSocket callback and those active. +func (jw *Jaws) RequestCount() (n int) { + jw.mu.RLock() + n = len(jw.requests) + jw.mu.RUnlock() + return +} -// SetPather is an alias for ui.SetPather. -// -// Deprecated: use ui.SetPather directly. -// -//go:fix inline -type SetPather = ui.SetPather +// Log sends an error to the Logger set in the Jaws. +// Has no effect if the err is nil or the Logger is nil. +// Returns err. +func (jw *Jaws) Log(err error) error { + if err != nil && jw != nil && jw.Logger != nil { + jw.Logger.Error("jaws", "err", err) + } + return err +} -// JsVar is an alias for ui.JsVar. -// -// Deprecated: use ui.JsVar directly. -// -//go:fix inline -type JsVar[T any] = ui.JsVar[T] +// MustLog sends an error to the Logger set in the Jaws or +// panics with the given error if no Logger is set. +// Has no effect if the err is nil. +func (jw *Jaws) MustLog(err error) { + if err != nil { + if jw != nil && jw.Logger != nil { + jw.Logger.Error("jaws", "err", err) + } else { + panic(err) + } + } +} -// IsJsVar is an alias for ui.IsJsVar. -// -// Deprecated: use ui.IsJsVar directly. -// -//go:fix inline -type IsJsVar = ui.IsJsVar +// NextID returns an int64 unique within lifetime of the program. +func NextID() int64 { + return atomic.AddInt64((*int64)(&NextJid), 1) +} -// JsVarMaker is an alias for ui.JsVarMaker. -// -// Deprecated: use ui.JsVarMaker directly. -// -//go:fix inline -type JsVarMaker = ui.JsVarMaker +// AppendID appends the result of NextID() in text form to the given slice. +func AppendID(b []byte) []byte { + return strconv.AppendInt(b, NextID(), 32) +} -// With is an alias for ui.With. -// -// Deprecated: use ui.With directly. -// -//go:fix inline -type With = ui.With +// MakeID returns a string in the form 'jaws.X' where X is a unique string within lifetime of the program. +func MakeID() string { + return string(AppendID([]byte("jaws."))) +} -// NewTemplate creates a new ui.Template. -// -// Deprecated: use ui.NewTemplate directly. -// -//go:fix inline -func NewTemplate(name string, dot any) Template { - return ui.NewTemplate(name, dot) +// NewRequest returns a new pending JaWS request. +// +// Call this as soon as you start processing a HTML request, and store the +// returned Request pointer so it can be used while constructing the HTML +// response in order to register the JaWS id's you use in the response, and +// use it's Key attribute when sending the Javascript portion of the reply. +// +// Automatic timeout handling is performed by ServeWithTimeout. The default +// Serve() helper uses a 10-second timeout. +func (jw *Jaws) NewRequest(hr *http.Request) (rq *Request) { + jw.mu.Lock() + defer jw.mu.Unlock() + for rq == nil { + jawsKey := jw.nonZeroRandomLocked() + if _, ok := jw.requests[jawsKey]; !ok { + rq = jw.getRequestLocked(jawsKey, hr) + jw.requests[jawsKey] = rq + } + } + return } -// NewJsVar creates a new ui.JsVar. -// -// Deprecated: use ui.NewJsVar directly. -// -//go:fix inline -func NewJsVar[T any](l sync.Locker, v *T) *JsVar[T] { - return ui.NewJsVar(l, v) +func (jw *Jaws) nonZeroRandomLocked() (val uint64) { + random := make([]byte, 8) + for val == 0 { + if _, err := io.ReadFull(jw.kg, random); err != nil { + panic(err) + } + val = binary.LittleEndian.Uint64(random) + } + return } -// UiA is an alias for ui.A. -// -// Deprecated: use ui.A directly. -// -//go:fix inline -type UiA = ui.A +// UseRequest extracts the JaWS request with the given key from the request +// map if it exists and the HTTP request remote IP matches. +// +// Call it when receiving the WebSocket connection on '/jaws/:key' to get the +// associated Request, and then call it's ServeHTTP method to process the +// WebSocket messages. +// +// Returns nil if the key was not found or the IP doesn't match, in which +// case you should return a HTTP "404 Not Found" status. +func (jw *Jaws) UseRequest(jawsKey uint64, hr *http.Request) (rq *Request) { + if jawsKey != 0 { + var err error + jw.mu.Lock() + if waitingRq, ok := jw.requests[jawsKey]; ok { + if err = waitingRq.claim(hr); err == nil { + rq = waitingRq + } + } + jw.mu.Unlock() + _ = jw.Log(err) + } + return +} -// UiButton is an alias for ui.Button. -// -// Deprecated: use ui.Button directly. -// -//go:fix inline -type UiButton = ui.Button +// SessionCount returns the number of active sessions. +func (jw *Jaws) SessionCount() (n int) { + jw.mu.RLock() + n = len(jw.sessions) + jw.mu.RUnlock() + return +} -// UiCheckbox is an alias for ui.Checkbox. -// -// Deprecated: use ui.Checkbox directly. -// -//go:fix inline -type UiCheckbox = ui.Checkbox +// Sessions returns a list of all active sessions, which may be nil. +func (jw *Jaws) Sessions() (sl []*Session) { + jw.mu.RLock() + if n := len(jw.sessions); n > 0 { + sl = make([]*Session, 0, n) + for _, sess := range jw.sessions { + sl = append(sl, sess) + } + } + jw.mu.RUnlock() + return +} -// UiContainer is an alias for ui.Container. -// -// Deprecated: use ui.Container directly. -// -//go:fix inline -type UiContainer = ui.Container +func (jw *Jaws) getSessionLocked(sessIds []uint64, remoteIP netip.Addr) *Session { + for _, sessId := range sessIds { + if sess, ok := jw.sessions[sessId]; ok && equalIP(remoteIP, sess.remoteIP) { + if !sess.isDead() { + return sess + } + } + } + return nil +} -// UiDate is an alias for ui.Date. -// -// Deprecated: use ui.Date directly. -// -//go:fix inline -type UiDate = ui.Date +func cutString(s string, sep byte) (before, after string) { + if i := strings.IndexByte(s, sep); i >= 0 { + return s[:i], s[i+1:] + } + return s, "" +} -// UiDiv is an alias for ui.Div. -// -// Deprecated: use ui.Div directly. -// -//go:fix inline -type UiDiv = ui.Div +func getCookieSessionsIds(h http.Header, wanted string) (cookies []uint64) { + for _, line := range h["Cookie"] { + if strings.Contains(line, wanted) { + var part string + line = textproto.TrimString(line) + for len(line) > 0 { + part, line = cutString(line, ';') + if part = textproto.TrimString(part); part != "" { + name, val := cutString(part, '=') + name = textproto.TrimString(name) + if name == wanted { + if len(val) > 1 && val[0] == '"' && val[len(val)-1] == '"' { + val = val[1 : len(val)-1] + } + if sessId := assets.JawsKeyValue(val); sessId != 0 { + cookies = append(cookies, sessId) + } + } + } + } + } + } + return +} -// UiImg is an alias for ui.Img. -// -// Deprecated: use ui.Img directly. -// -//go:fix inline -type UiImg = ui.Img +// GetSession returns the Session associated with the given *http.Request, or nil. +func (jw *Jaws) GetSession(hr *http.Request) (sess *Session) { + if hr != nil { + if sessIds := getCookieSessionsIds(hr.Header, jw.CookieName); len(sessIds) > 0 { + remoteIP := parseIP(hr.RemoteAddr) + jw.mu.RLock() + sess = jw.getSessionLocked(sessIds, remoteIP) + jw.mu.RUnlock() + } + } + return +} -// UiLabel is an alias for ui.Label. -// -// Deprecated: use ui.Label directly. -// -//go:fix inline -type UiLabel = ui.Label +// NewSession creates a new Session. +// +// Any pre-existing Session will be cleared and closed. +// This may call Session.Close() on an existing session and therefore requires +// the JaWS processing loop (`Serve()` or `ServeWithTimeout()`) to be running. +// +// Subsequent Requests created with `NewRequest()` that have the cookie set and +// originates from the same IP will be able to access the Session. +func (jw *Jaws) NewSession(w http.ResponseWriter, hr *http.Request) (sess *Session) { + if hr != nil { + if oldSess := jw.GetSession(hr); oldSess != nil { + oldSess.Clear() + oldSess.Close() + } + sess = jw.newSession(w, hr) + } + return +} -// UiLi is an alias for ui.Li. -// -// Deprecated: use ui.Li directly. -// -//go:fix inline -type UiLi = ui.Li +func (jw *Jaws) newSession(w http.ResponseWriter, hr *http.Request) (sess *Session) { + secure := requestIsSecure(hr) + jw.mu.Lock() + defer jw.mu.Unlock() + for sess == nil { + sessionID := jw.nonZeroRandomLocked() + if _, ok := jw.sessions[sessionID]; !ok { + sess = newSession(jw, sessionID, parseIP(hr.RemoteAddr), secure) + jw.sessions[sessionID] = sess + if w != nil { + http.SetCookie(w, &sess.cookie) + } + hr.AddCookie(&sess.cookie) + } + } + return +} -// UiNumber is an alias for ui.Number. -// -// Deprecated: use ui.Number directly. -// -//go:fix inline -type UiNumber = ui.Number +func (jw *Jaws) deleteSession(sessionID uint64) { + jw.mu.Lock() + delete(jw.sessions, sessionID) + jw.mu.Unlock() +} -// UiPassword is an alias for ui.Password. -// -// Deprecated: use ui.Password directly. -// -//go:fix inline -type UiPassword = ui.Password +func (jw *Jaws) FaviconURL() (s string) { + jw.mu.RLock() + s = jw.faviconURL + jw.mu.RUnlock() + return +} -// UiRadio is an alias for ui.Radio. -// -// Deprecated: use ui.Radio directly. -// -//go:fix inline -type UiRadio = ui.Radio +// ContentSecurityPolicy returns the generated Content-Security-Policy header value. +func (jw *Jaws) ContentSecurityPolicy() (s string) { + jw.mu.RLock() + s = jw.cspHeader + jw.mu.RUnlock() + return +} -// UiRange is an alias for ui.Range. -// -// Deprecated: use ui.Range directly. -// -//go:fix inline -type UiRange = ui.Range +// SecureHeadersMiddleware wraps next with security headers that match the +// current JaWS configuration. +// +// It snapshots secureheaders.DefaultHeaders, replacing the +// Content-Security-Policy value with ContentSecurityPolicy so responses allow +// the resources configured by GenerateHeadHTML. +// +// The returned middleware does not trust forwarded HTTPS headers. +// The next handler must be non-nil. +func (jw *Jaws) SecureHeadersMiddleware(next http.Handler) http.Handler { + hdrs := maps.Clone(secureheaders.DefaultHeaders) + hdrs["Content-Security-Policy"] = []string{jw.ContentSecurityPolicy()} + return secureheaders.Middleware{ + Handler: next, + Header: hdrs, + } +} -// UiSelect is an alias for ui.Select. -// -// Deprecated: use ui.Select directly. -// -//go:fix inline -type UiSelect = ui.Select +// GenerateHeadHTML (re-)generates the HTML code that goes in the HEAD section, ensuring +// that the provided URL resources in `extra` are loaded, along with the JaWS javascript. +// If one of the resources is named "favicon", it's URL will be stored and can +// be retrieved using FaviconURL(). +// +// You only need to call this if you add your own images, scripts and stylesheets. +func (jw *Jaws) GenerateHeadHTML(extra ...string) (err error) { + var jawsurl *url.URL + if jawsurl, err = url.Parse(jw.serveJS.Name); err == nil { + var cssurl *url.URL + if cssurl, err = url.Parse(jw.serveCSS.Name); err == nil { + var urls []*url.URL + urls = append(urls, cssurl) + urls = append(urls, jawsurl) + for _, urlstr := range extra { + if u, e := url.Parse(urlstr); e == nil { + if !strings.HasSuffix(u.Path, jawsurl.Path) { + urls = append(urls, u) + } + } else { + err = errors.Join(err, e) + } + } + headPrefix, faviconURL := assets.PreloadHTML(urls...) + headPrefix += ` maxInterval { + maintenanceInterval = maxInterval + } + if maintenanceInterval < minInterval { + maintenanceInterval = minInterval + } + + subs := map[chan wire.Message]*Request{} + t := time.NewTicker(maintenanceInterval) + + defer func() { + t.Stop() + for ch, rq := range subs { + rq.cancel(nil) + close(ch) + } + }() + + killSub := func(msgCh chan wire.Message) { + if _, ok := subs[msgCh]; ok { + delete(subs, msgCh) + close(msgCh) + } + } + + // it's critical that we keep the broadcast + // distribution loop running, so any Request + // that fails to process it's messages quickly + // enough must be terminated. the alternative + // would be to drop some messages, but that + // could mean nonreproducible and seemingly + // random failures in processing logic. + mustBroadcast := func(msg wire.Message) { + for msgCh, rq := range subs { + if msg.Dest == nil || rq.wantMessage(&msg) { + select { + case msgCh <- msg: + default: + // the exception is Update messages, more will follow eventually + if msg.What != what.Update { + killSub(msgCh) + rq.cancel(fmt.Errorf("%v: broadcast channel full sending %s", rq, msg.String())) + } + } + } + } + } + + for { + select { + case <-jw.Done(): + return + case <-jw.updateTicker.C: + if jw.distributeDirt() > 0 { + mustBroadcast(wire.Message{What: what.Update}) + } + case <-t.C: + jw.maintenance(requestTimeout) + case sub := <-jw.subCh: + if sub.msgCh != nil { + subs[sub.msgCh] = sub.rq + } + case msgCh := <-jw.unsubCh: + killSub(msgCh) + case msg, ok := <-jw.bcastCh: + if ok { + mustBroadcast(msg) + } + } + } } -// NewUiLabel creates a new ui.Label. -// -// Deprecated: use ui.NewLabel directly. -// -//go:fix inline -func NewUiLabel(innerHTML HTMLGetter) *UiLabel { - return ui.NewLabel(innerHTML) +// Serve calls ServeWithTimeout(time.Second * 10). +// It is intended to run on it's own goroutine. +// It returns when Close is called. +func (jw *Jaws) Serve() { + jw.ServeWithTimeout(time.Second * 10) } -// NewUiLi creates a new ui.Li. -// -// Deprecated: use ui.NewLi directly. -// -//go:fix inline -func NewUiLi(innerHTML HTMLGetter) *UiLi { - return ui.NewLi(innerHTML) +func (jw *Jaws) subscribe(rq *Request, size int) chan wire.Message { + msgCh := make(chan wire.Message, size) + select { + case <-jw.Done(): + close(msgCh) + return nil + case jw.subCh <- subscription{msgCh: msgCh, rq: rq}: + } + return msgCh } -// NewUiSelect creates a new ui.Select. -// -// Deprecated: use ui.NewSelect directly. -// -//go:fix inline -func NewUiSelect(sh SelectHandler) *UiSelect { - return ui.NewSelect(sh) +func (jw *Jaws) unsubscribe(msgCh chan wire.Message) { + select { + case <-jw.Done(): + case jw.unsubCh <- msgCh: + } } -// NewUiSpan creates a new ui.Span. -// -// Deprecated: use ui.NewSpan directly. -// -//go:fix inline -func NewUiSpan(innerHTML HTMLGetter) *UiSpan { - return ui.NewSpan(innerHTML) +func (jw *Jaws) maintenance(requestTimeout time.Duration) { + jw.mu.Lock() + defer jw.mu.Unlock() + now := time.Now() + for _, rq := range jw.requests { + if rq.maintenance(now, requestTimeout) { + jw.recycleLocked(rq) + } + } + for k, sess := range jw.sessions { + if sess.isDead() { + delete(jw.sessions, k) + } + } } -// NewUiTbody creates a new ui.Tbody. -// -// Deprecated: use ui.NewTbody directly. -// -//go:fix inline -func NewUiTbody(c Container) *UiTbody { - return ui.NewTbody(c) +func equalIP(a, b netip.Addr) bool { + return a.Compare(b) == 0 || (a.IsLoopback() && b.IsLoopback()) } -// NewUiTd creates a new ui.Td. -// -// Deprecated: use ui.NewTd directly. -// -//go:fix inline -func NewUiTd(innerHTML HTMLGetter) *UiTd { - return ui.NewTd(innerHTML) +func parseIP(remoteAddr string) (ip netip.Addr) { + if remoteAddr != "" { + if host, _, err := net.SplitHostPort(remoteAddr); err == nil { + ip, _ = netip.ParseAddr(host) + } else { + ip, _ = netip.ParseAddr(remoteAddr) + } + } + return } -// NewUiTr creates a new ui.Tr. -// -// Deprecated: use ui.NewTr directly. -// -//go:fix inline -func NewUiTr(innerHTML HTMLGetter) *UiTr { - return ui.NewTr(innerHTML) +func requestIsSecure(hr *http.Request) (yes bool) { + yes = secureheaders.RequestIsSecure(hr, true) + return } -// NewUiCheckbox creates a new ui.Checkbox. -// -// Deprecated: use ui.NewCheckbox directly. -// -//go:fix inline -func NewUiCheckbox(g Setter[bool]) *UiCheckbox { - return ui.NewCheckbox(g) +func maybePanic(err error) { + if err != nil { + panic(err) + } } -// NewUiDate creates a new ui.Date. -// -// Deprecated: use ui.NewDate directly. -// -//go:fix inline -func NewUiDate(g Setter[time.Time]) *UiDate { - return ui.NewDate(g) +// 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(wire.Message{ + Dest: target, + What: what.Inner, + Data: string(innerHTML), + }) } -// NewUiImg creates a new ui.Img. -// -// Deprecated: use ui.NewImg directly. -// -//go:fix inline -func NewUiImg(g Getter[string]) *UiImg { - return ui.NewImg(g) +// 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(wire.Message{ + Dest: target, + What: what.SAttr, + Data: attr + "\n" + val, + }) } -// NewUiNumber creates a new ui.Number. -// -// Deprecated: use ui.NewNumber directly. -// -//go:fix inline -func NewUiNumber(g Setter[float64]) *UiNumber { - return ui.NewNumber(g) +// 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(wire.Message{ + Dest: target, + What: what.RAttr, + Data: attr, + }) } -// NewUiPassword creates a new ui.Password. -// -// Deprecated: use ui.NewPassword directly. -// -//go:fix inline -func NewUiPassword(g Setter[string]) *UiPassword { - return ui.NewPassword(g) +// 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(wire.Message{ + Dest: target, + What: what.SClass, + Data: cls, + }) } -// NewUiRadio creates a new ui.Radio. -// -// Deprecated: use ui.NewRadio directly. -// -//go:fix inline -func NewUiRadio(vp Setter[bool]) *UiRadio { - return ui.NewRadio(vp) +// 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(wire.Message{ + Dest: target, + What: what.RClass, + Data: cls, + }) } -// NewUiRange creates a new ui.Range. -// -// Deprecated: use ui.NewRange directly. -// -//go:fix inline -func NewUiRange(g Setter[float64]) *UiRange { - return ui.NewRange(g) +// 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(wire.Message{ + Dest: target, + What: what.Value, + Data: val, + }) } -// NewUiText creates a new ui.Text. +// Insert calls the Javascript 'insertBefore()' method on +// all HTML elements matching target. // -// Deprecated: use ui.NewText directly. -// -//go:fix inline -func NewUiText(vp Setter[string]) *UiText { - return ui.NewText(vp) +// 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(wire.Message{ + Dest: target, + What: what.Insert, + Data: where + "\n" + html, + }) +} + +// Replace replaces HTML on all HTML elements matching target. +func (jw *Jaws) Replace(target any, html string) { + jw.Broadcast(wire.Message{ + Dest: target, + What: what.Replace, + Data: html, + }) +} + +// Delete removes the HTML element(s) matching target. +func (jw *Jaws) Delete(target any) { + jw.Broadcast(wire.Message{ + Dest: target, + What: what.Delete, + }) +} + +// Append calls the Javascript 'appendChild()' method on all HTML elements matching target. +func (jw *Jaws) Append(target any, html template.HTML) { + jw.Broadcast(wire.Message{ + Dest: target, + What: what.Append, + Data: string(html), + }) +} + +func maybeCompactJSON(in string) (out string) { + out = in + if strings.ContainsAny(in, "\n\t") { + var b bytes.Buffer + if err := json.Compact(&b, []byte(in)); err == nil { + out = b.String() + } + } + return +} + +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(wire.Message{ + Dest: tag, + What: what.Call, + Data: whitespaceRemover.Replace(jsfunc) + "=" + maybeCompactJSON(jsonstr), + }) +} + +func (jw *Jaws) getRequestLocked(jawsKey uint64, hr *http.Request) (rq *Request) { + rq = jw.reqPool.Get().(*Request) + rq.JawsKey = jawsKey + rq.lastWrite = time.Now() + rq.initial = hr + rq.ctx, rq.cancelFn = context.WithCancelCause(jw.BaseContext) + if hr != nil { + rq.remoteIP = parseIP(hr.RemoteAddr) + if sess := jw.getSessionLocked(getCookieSessionsIds(hr.Header, jw.CookieName), rq.remoteIP); sess != nil { + sess.addRequest(rq) + rq.session = sess + } + } + return rq +} + +func (jw *Jaws) recycleLocked(rq *Request) { + rq.mu.Lock() + defer rq.mu.Unlock() + if rq.JawsKey != 0 { + delete(jw.requests, rq.JawsKey) + rq.clearLocked() + jw.reqPool.Put(rq) + } +} + +func (jw *Jaws) recycle(rq *Request) { + jw.mu.Lock() + defer jw.mu.Unlock() + jw.recycleLocked(rq) +} + +var headerCacheControlNoStore = []string{"no-store"} + +// ServeHTTP can handle the required JaWS endpoints, which all start with "/jaws/". +func (jw *Jaws) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if len(r.URL.Path) > 6 && strings.HasPrefix(r.URL.Path, "/jaws/") { + if r.URL.Path[6] == '.' { + switch r.URL.Path { + case jw.serveCSS.Name: + jw.serveCSS.ServeHTTP(w, r) + return + case jw.serveJS.Name: + jw.serveJS.ServeHTTP(w, r) + return + case "/jaws/.ping": + w.Header()["Cache-Control"] = headerCacheControlNoStore + select { + case <-jw.Done(): + w.WriteHeader(http.StatusServiceUnavailable) + default: + w.WriteHeader(http.StatusNoContent) + } + return + default: + if jawsKeyString, ok := strings.CutPrefix(r.URL.Path, "/jaws/.tail/"); ok { + jawsKey := assets.JawsKeyValue(jawsKeyString) + jw.mu.RLock() + rq := jw.requests[jawsKey] + jw.mu.RUnlock() + if rq != nil { + if err := rq.writeTailScriptResponse(w); err != nil { + rq.cancel(err) + } + return + } + } + } + } else if rq := jw.UseRequest(assets.JawsKeyValue(r.URL.Path[6:]), r); rq != nil { + rq.ServeHTTP(w, r) + return + } + } + w.WriteHeader(http.StatusNotFound) +} + +type sessioner struct { + jw *Jaws + h http.Handler +} + +func (sess sessioner) ServeHTTP(wr http.ResponseWriter, r *http.Request) { + if sess.jw.GetSession(r) == nil { + sess.jw.newSession(wr, r) + } + sess.h.ServeHTTP(wr, r) +} + +// Session returns a http.Handler that ensures a JaWS Session exists before invoking h. +func (jw *Jaws) Session(h http.Handler) http.Handler { + return sessioner{jw: jw, h: h} } diff --git a/jaws_test.go b/jaws_test.go index 5a7875b0..f7ef8c61 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -1,198 +1,874 @@ -package jaws_test - -// just to satisfy test coverage +package jaws import ( + "bufio" + "bytes" + "compress/gzip" + "errors" "html/template" + "mime" "net/http" "net/http/httptest" + "net/url" + "reflect" "strings" "sync" "testing" "time" - "github.com/linkdata/jaws" - "github.com/linkdata/jaws/ui" + "github.com/linkdata/jaws/lib/assets" + "github.com/linkdata/jaws/lib/jtag" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" + "github.com/linkdata/secureheaders" + "github.com/linkdata/staticserve" ) -const testPageTmplText = "({{with .Dot}}" + - "{{$.Initial.URL.Path}}" + - "{{$.A `a`}}" + - "{{$.Button `button`}}" + - "{{$.Checkbox .TheBool `checkbox`}}" + - "{{$.Container `container` .TheContainer}}" + - "{{$.Date .TheTime `dateattr`}}" + - "{{$.Div `div`}}" + - "{{$.Img `img`}}" + - "{{$.Label `label`}}" + - "{{$.Li `li`}}" + - "{{$.Number .TheNumber}}" + - "{{$.Password .TheString}}" + - "{{$.Radio .TheBool}}" + - "{{$.Range .TheNumber}}" + - "{{$.Select .TheSelector}}" + - "{{$.Span `span`}}" + - "{{$.Tbody .TheContainer}}" + - "{{$.Td `td`}}" + - "{{$.Template `nested` .TheDot `someattr`}}" + - "{{$.Text .TheString}}" + - "{{$.Textarea .TheString}}" + - "{{$.Tr `tr`}}" + - "{{end}})" -const testPageNestedTmplText = "" + - "{{$.Initial.URL.Path}}" + - "{{with .Dot}}{{.}}{{$.Span `span2`}}{{end}}" + - "" - -const testPageWant = "(" + - "/" + - "a" + - "" + - "" + - "" + - "" + - "
div
" + - "" + - "" + - "
  • li
  • " + - "" + - "" + - "" + - "" + - "" + - "span" + - "" + - "td" + - "/dotspan2" + - "" + - "" + - "tr" + - ")" - -type testContainer struct{ contents []jaws.UI } - -func (tc *testContainer) JawsContains(e *jaws.Element) (contents []jaws.UI) { - return tc.contents -} - -type testPage struct { - jaws.RequestWriter - TheBool jaws.Setter[bool] - TheContainer jaws.Container - TheTime jaws.Setter[time.Time] - TheNumber jaws.Setter[float64] - TheString jaws.Setter[string] - TheSelector jaws.SelectHandler - TheDot any -} - -func maybeFatal(t *testing.T, err error) { +type testBroadcastTagGetter struct{} + +func (testBroadcastTagGetter) JawsGetTag(jtag.Context) any { + return jtag.Tag("expanded") +} + +func TestCoverage_GenerateHeadAndConvenienceBroadcasts(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + if err := jw.GenerateHeadHTML("%zz"); err == nil { + t.Fatal("expected url parse error") + } + if err := jw.GenerateHeadHTML("/favicon.ico", "/app.js"); err != nil { + t.Fatal(err) + } + + jw.Reload() + if msg := nextBroadcast(t, jw); msg.What != what.Reload { + t.Fatalf("unexpected reload msg %#v", msg) + } + jw.Redirect("/next") + if msg := nextBroadcast(t, jw); msg.What != what.Redirect || msg.Data != "/next" { + t.Fatalf("unexpected redirect msg %#v", msg) + } + jw.Alert("info", "hello") + if msg := nextBroadcast(t, jw); msg.What != what.Alert || msg.Data != "info\nhello" { + t.Fatalf("unexpected alert msg %#v", msg) + } + + jw.SetInner("t", template.HTML("x")) + if msg := nextBroadcast(t, jw); msg.What != what.Inner || msg.Data != "x" { + t.Fatalf("unexpected set inner msg %#v", msg) + } + jw.SetAttr("t", "k", "v") + if msg := nextBroadcast(t, jw); msg.What != what.SAttr || msg.Data != "k\nv" { + t.Fatalf("unexpected set attr msg %#v", msg) + } + jw.RemoveAttr("t", "k") + if msg := nextBroadcast(t, jw); msg.What != what.RAttr || msg.Data != "k" { + t.Fatalf("unexpected remove attr msg %#v", msg) + } + jw.SetClass("t", "c") + if msg := nextBroadcast(t, jw); msg.What != what.SClass || msg.Data != "c" { + t.Fatalf("unexpected set class msg %#v", msg) + } + jw.RemoveClass("t", "c") + if msg := nextBroadcast(t, jw); msg.What != what.RClass || msg.Data != "c" { + t.Fatalf("unexpected remove class msg %#v", msg) + } + jw.SetValue("t", "v") + if msg := nextBroadcast(t, jw); msg.What != what.Value || msg.Data != "v" { + t.Fatalf("unexpected set value msg %#v", msg) + } + jw.Insert("t", "0", "a") + if msg := nextBroadcast(t, jw); msg.What != what.Insert || msg.Data != "0\na" { + t.Fatalf("unexpected insert msg %#v", msg) + } + jw.Replace("t", "b") + if msg := nextBroadcast(t, jw); msg.What != what.Replace || msg.Data != "b" { + t.Fatalf("unexpected replace msg %#v", msg) + } + jw.Delete("t") + if msg := nextBroadcast(t, jw); msg.What != what.Delete { + t.Fatalf("unexpected delete msg %#v", msg) + } + jw.Append("t", "c") + if msg := nextBroadcast(t, jw); msg.What != what.Append || msg.Data != "c" { + t.Fatalf("unexpected append msg %#v", msg) + } + jw.JsCall("t", "fn", `{"a":1}`) + if msg := nextBroadcast(t, jw); msg.What != what.Call || msg.Data != `fn={"a":1}` { + t.Fatalf("unexpected jscall msg %#v", msg) + } +} + +func TestBroadcast_ExpandsTagDestBeforeQueue(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + tagger := testBroadcastTagGetter{} + + jw.Broadcast(wire.Message{ + Dest: tagger, + What: what.Inner, + Data: "x", + }) + msg := nextBroadcast(t, jw) + if msg.What != what.Inner || msg.Data != "x" { + t.Fatalf("unexpected msg %#v", msg) + } + if got, ok := msg.Dest.(jtag.Tag); !ok || got != jtag.Tag("expanded") { + t.Fatalf("expected expanded Tag destination, got %T(%#v)", msg.Dest, msg.Dest) + } + + jw.Broadcast(wire.Message{ + Dest: []any{tagger, jtag.Tag("extra")}, + What: what.Value, + Data: "v", + }) + msg = nextBroadcast(t, jw) + if msg.What != what.Value || msg.Data != "v" { + t.Fatalf("unexpected msg %#v", msg) + } + dest, ok := msg.Dest.([]any) + if !ok { + t.Fatalf("expected []any destination, got %T(%#v)", msg.Dest, msg.Dest) + } + if len(dest) != 2 || dest[0] != jtag.Tag("expanded") || dest[1] != jtag.Tag("extra") { + t.Fatalf("unexpected expanded destination %#v", dest) + } + + jw.Broadcast(wire.Message{ + Dest: "html-id", + What: what.Delete, + }) + msg = nextBroadcast(t, jw) + if got, ok := msg.Dest.(string); !ok || got != "html-id" { + t.Fatalf("expected raw html-id destination, got %T(%#v)", msg.Dest, msg.Dest) + } +} + +func TestBroadcast_NoneDestination(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + jw.Broadcast(wire.Message{ + Dest: []any{}, + What: what.Update, + Data: "x", + }) + + select { + case msg := <-jw.bcastCh: + t.Fatalf("expected no pending broadcast, got %T(%#v)", msg.Dest, msg.Dest) + default: + } +} + +func TestBroadcast_ReturnsWhenClosedAndQueueFull(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + jw.Broadcast(wire.Message{What: what.Alert, Data: "info\nfirst"}) + jw.Close() + + done := make(chan struct{}) + go func() { + jw.Broadcast(wire.Message{What: what.Alert, Data: "info\nsecond"}) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("broadcast blocked after close") + } + + msg := nextBroadcast(t, jw) + if msg.Data != "info\nfirst" { + t.Fatalf("unexpected queued message %#v", msg) + } + select { + case extra := <-jw.bcastCh: + t.Fatalf("unexpected extra message after close %#v", extra) + default: + } +} + +func mustParseURL(t *testing.T, raw string) *url.URL { t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func TestJaws_GenerateHeadHTML_StoresCSPBuiltBySecureHeaders(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + extras := []string{ + "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css", + "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.min.js", + "https://images.example.com/logo.png", + } + if err = jw.GenerateHeadHTML(extras...); err != nil { + t.Fatal(err) + } + + urls := []*url.URL{ + mustParseURL(t, jw.serveCSS.Name), + mustParseURL(t, jw.serveJS.Name), + } + for _, extra := range extras { + urls = append(urls, mustParseURL(t, extra)) + } + + wantCSP, err := secureheaders.BuildContentSecurityPolicy(urls) + if err != nil { + t.Fatal(err) + } + if got := jw.ContentSecurityPolicy(); got != wantCSP { + t.Fatalf("unexpected CSP:\nwant: %q\ngot: %q", wantCSP, got) + } +} + +func TestJaws_GenerateHeadHTML_PropagatesResourceParseErrors(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + err = jw.GenerateHeadHTML("https://bad host") + if err == nil { + t.Fatal("expected parse error for extra resource URL") + } + if !strings.Contains(err.Error(), "invalid character") { + t.Fatalf("expected parse error, got: %v", err) + } +} + +func TestJaws_SecureHeadersMiddleware_UsesJawsCSP(t *testing.T) { + jw, err := New() if err != nil { t.Fatal(err) } + defer jw.Close() + + if err = jw.GenerateHeadHTML( + "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css", + "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.min.js", + ); err != nil { + t.Fatal(err) + } + wantCSP := jw.ContentSecurityPolicy() + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodGet, "https://example.test/", nil) + rr := httptest.NewRecorder() + jw.SecureHeadersMiddleware(next).ServeHTTP(rr, req) + + if !nextCalled { + t.Fatal("expected wrapped handler to be called") + } + if got := rr.Result().StatusCode; got != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, got) + } + + hdr := rr.Result().Header + if got := hdr.Get("Content-Security-Policy"); got != wantCSP { + t.Fatalf("expected CSP %q, got %q", wantCSP, got) + } + if got := hdr.Get("Strict-Transport-Security"); got != secureheaders.DefaultHeaders.Get("Strict-Transport-Security") { + t.Fatalf("expected HSTS %q, got %q", secureheaders.DefaultHeaders.Get("Strict-Transport-Security"), got) + } +} + +func TestJaws_SecureHeadersMiddleware_ClonesDefaultHeaders(t *testing.T) { + orig := secureheaders.DefaultHeaders.Clone() + defer func() { + secureheaders.DefaultHeaders = orig + }() + + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + wantCSP := jw.ContentSecurityPolicy() + mw := jw.SecureHeadersMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + secureheaders.DefaultHeaders.Set("X-Frame-Options", "SAMEORIGIN") + secureheaders.DefaultHeaders.Set("Content-Security-Policy", "default-src 'none'") + + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.test/", nil)) + hdr := rr.Result().Header + + if got := hdr.Get("X-Frame-Options"); got != orig.Get("X-Frame-Options") { + t.Fatalf("expected X-Frame-Options %q, got %q", orig.Get("X-Frame-Options"), got) + } + if got := hdr.Get("Content-Security-Policy"); got != wantCSP { + t.Fatalf("expected CSP %q, got %q", wantCSP, got) + } } -var onlyOnce sync.Once +func TestJaws_SecureHeadersMiddleware_DoesNotTrustForwardedHeaders(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() -func TestNewTemplate(t *testing.T) { - onlyOnce.Do(func() { - jw, err := jaws.New() - maybeFatal(t, err) - defer jw.Close() + mw := jw.SecureHeadersMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - jw.AddTemplateLookuper(template.Must(template.New("nested").Parse(testPageNestedTmplText))) - jw.AddTemplateLookuper(template.Must(template.New("normal").Parse(testPageTmplText))) + req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + req.Header.Set("X-Forwarded-Proto", "https") + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) - jw.UseRequest(rq.JawsKey, hr) - var sb strings.Builder - rqwr := ui.RequestWriter{Request: rq, Writer: &sb} + if got := rr.Result().Header.Get("Strict-Transport-Security"); got != "" { + t.Fatalf("expected no HSTS over HTTP request with forwarded proto, got %q", got) + } +} - var mu sync.RWMutex - vbool := true - vtime, _ := time.Parse(jaws.ISO8601, "1901-02-03") - vnumber := float64(1.2) - vstring := "bar" - nba := jaws.NewNamedBoolArray(false) +func TestJaws_distributeDirt_AscendingOrder(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() - tp := &testPage{ - TheBool: jaws.Bind(&mu, &vbool), - TheContainer: &testContainer{}, - TheTime: jaws.Bind(&mu, &vtime), - TheNumber: jaws.Bind(&mu, &vnumber), - TheString: jaws.Bind(&mu, &vstring), - TheSelector: nba, - TheDot: jaws.Tag("dot"), + rq := &Request{} + jw.mu.Lock() + jw.requests[1] = rq + jw.dirty[jtag.Tag("fourth")] = 4 + jw.dirty[jtag.Tag("second")] = 2 + jw.dirty[jtag.Tag("fifth")] = 5 + jw.dirty[jtag.Tag("first")] = 1 + jw.dirty[jtag.Tag("third")] = 3 + jw.dirtOrder = 5 + jw.mu.Unlock() + + if got, want := jw.distributeDirt(), 5; got != want { + t.Fatalf("distributeDirt() = %d, want %d", got, want) + } + + rq.mu.RLock() + got := append([]any(nil), rq.todoDirt...) + rq.mu.RUnlock() + + want := []any{ + jtag.Tag("first"), + jtag.Tag("second"), + jtag.Tag("third"), + jtag.Tag("fourth"), + jtag.Tag("fifth"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("dirty tags = %#v, want %#v", got, want) + } +} + +func TestJaws_GenerateHeadHTMLConcurrentWithHeadHTML(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + if err := jw.GenerateHeadHTML("/a.js", "/b.css"); err != nil { + t.Error(err) + return + } + } + } + }() + + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + var buf bytes.Buffer + if err := rq.HeadHTML(&buf); err != nil { + t.Error(err) + } + jw.recycle(rq) + } } + }() + + time.Sleep(50 * time.Millisecond) + close(stop) + wg.Wait() +} + +func TestCoverage_IDAndLookupHelpers(t *testing.T) { + NextJid = 0 + if a, b := NextID(), NextID(); b <= a { + t.Fatalf("expected increasing ids, got %d then %d", a, b) + } + if got := string(AppendID([]byte("x"))); !strings.HasPrefix(got, "x") || len(got) <= 1 { + t.Fatalf("unexpected append id result %q", got) + } + if got := MakeID(); !strings.HasPrefix(got, "jaws.") { + t.Fatalf("unexpected id %q", got) + } + + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + tmpl := template.Must(template.New("it").Parse(`ok`)) + jw.AddTemplateLookuper(tmpl) + if got := jw.LookupTemplate("it"); got == nil { + t.Fatal("expected found template") + } + if got := jw.LookupTemplate("missing"); got != nil { + t.Fatal("expected missing template") + } + jw.RemoveTemplateLookuper(nil) + jw.RemoveTemplateLookuper(tmpl) + + hr := httptest.NewRequest(http.MethodGet, "/", nil) + rq := jw.NewRequest(hr) + if rq == nil { + t.Fatal("expected request") + } + if got := jw.RequestCount(); got != 1 { + t.Fatalf("expected one request, got %d", got) + } + jw.recycle(rq) + if got := jw.RequestCount(); got != 0 { + t.Fatalf("expected zero requests, got %d", got) + } +} + +func TestCoverage_CookieParseAndIP(t *testing.T) { + h := http.Header{} + 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") + if len(ids) != 2 || ids[0] != 11 || ids[1] != 12 { + t.Fatalf("unexpected cookie ids %#v", ids) + } + + if got := parseIP("127.0.0.1:1234"); !got.IsValid() { + t.Fatalf("expected parsed host:port ip, got %v", got) + } + if got := parseIP("::1"); !got.IsValid() { + t.Fatalf("expected parsed direct ip, got %v", got) + } + if got := parseIP(""); got.IsValid() { + t.Fatalf("expected invalid ip for empty remote addr, got %v", got) + } +} + +func TestCoverage_NonZeroRandomAndPanic(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() - tmpl := jaws.NewTemplate("normal", tp) - elem := rq.NewElement(tmpl) - err = tmpl.JawsRender(elem, rqwr, nil) - maybeFatal(t, err) + // First random value is zero, second is one. + zeroThenOne := append(make([]byte, 8), []byte{1, 0, 0, 0, 0, 0, 0, 0}...) + jw.kg = bufio.NewReader(bytes.NewReader(zeroThenOne)) + if got := jw.nonZeroRandomLocked(); got != 1 { + t.Fatalf("unexpected non-zero random value %d", got) + } - if s := sb.String(); s != testPageWant { - t.Errorf("\n got: %q\nwant: %q\n", s, testPageWant) + defer func() { + if recover() == nil { + t.Fatal("expected panic on random source read error") } - }) + }() + jw.kg = bufio.NewReader(errReader{}) + _ = jw.nonZeroRandomLocked() } -func TestJsVar(t *testing.T) { - var mu sync.RWMutex - vbool := true - _ = jaws.NewJsVar(&mu, &vbool) +func TestJaws_ServeWithTimeoutBounds(t *testing.T) { + // Min interval clamp path. + jwMin, err := New() + if err != nil { + t.Fatal(err) + } + doneMin := make(chan struct{}) + go func() { + jwMin.ServeWithTimeout(time.Nanosecond) + close(doneMin) + }() + jwMin.Close() + select { + case <-doneMin: + case <-time.After(time.Second): + t.Fatal("timeout waiting for ServeWithTimeout(min)") + } + + // Max interval clamp path. + jwMax, err := New() + if err != nil { + t.Fatal(err) + } + doneMax := make(chan struct{}) + go func() { + jwMax.ServeWithTimeout(10 * time.Second) + close(doneMax) + }() + jwMax.Close() + select { + case <-doneMax: + case <-time.After(time.Second): + t.Fatal("timeout waiting for ServeWithTimeout(max)") + } } -func TestJawsKeyString(t *testing.T) { - if s := jaws.JawsKeyString(1000); s != "v8" { - t.Error(s) +func TestJaws_ServeWithTimeoutFullSubscriberChannel(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + msgCh := make(chan wire.Message) // unbuffered: always full when nobody receives + done := make(chan struct{}) + go func() { + jw.ServeWithTimeout(50 * time.Millisecond) + close(done) + }() + jw.subCh <- subscription{msgCh: msgCh, rq: rq} + // Ensure ServeWithTimeout has consumed the subscription before broadcast. + for i := 0; i <= cap(jw.subCh); i++ { + jw.subCh <- subscription{} + } + jw.bcastCh <- wire.Message{What: what.Alert, Data: "x"} + + waitUntil := time.Now().Add(time.Second) + closed := false + for !closed && time.Now().Before(waitUntil) { + select { + case _, ok := <-msgCh: + closed = !ok + default: + time.Sleep(time.Millisecond) + } + } + if !closed { + t.Fatal("expected subscriber channel to be closed when full") + } + + jw.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for ServeWithTimeout exit") } } -func TestWriteHTMLTag(t *testing.T) { - var sb strings.Builder - err := jaws.WriteHTMLTag(&sb, 1, "foo", "checkbox", "false", []template.HTMLAttr{"someattr"}) - maybeFatal(t, err) - want := "" - if sb.String() != want { - t.Errorf("\n got: %q\nwant: %q\n", sb.String(), want) +var headerContentGZip = []string{"gzip"} + +type errResponseWriter struct { + code int + header http.Header + writeErr error + writeCall int +} + +func (w *errResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) } + return w.header } -func TestNewUi(t *testing.T) { - htmlGetter := jaws.MakeHTMLGetter("x") - htmlGetter2 := jaws.HTMLGetterFunc(func(elem *jaws.Element) (tmpl template.HTML) { - return "x" - }) - stringGetter := jaws.StringGetterFunc(func(elem *jaws.Element) (s string) { - return "s" - }) - var mu sync.RWMutex - vbool := true - vtime, _ := time.Parse(jaws.ISO8601, "1901-02-03") - vnumber := float64(1.2) - vstring := "bar" - nba := jaws.NewNamedBoolArray(false) - _ = jaws.NewNamedBool(nba, "escape\"me", "", true) - - jaws.NewUiA(htmlGetter) - jaws.NewUiButton(htmlGetter2) - jaws.NewUiCheckbox(jaws.Bind(&mu, &vbool)) - jaws.NewUiContainer("tbody", &testContainer{}) - jaws.NewUiDate(jaws.Bind(&mu, &vtime)) - jaws.NewUiDiv(htmlGetter) - jaws.NewUiImg(stringGetter) - jaws.NewUiLabel(htmlGetter) - jaws.NewUiLi(htmlGetter) - jaws.NewUiNumber(jaws.Bind(&mu, &vnumber)) - jaws.NewUiPassword(jaws.Bind(&mu, &vstring)) - jaws.NewUiRadio(jaws.Bind(&mu, &vbool)) - jaws.NewUiRange(jaws.Bind(&mu, &vnumber)) - jaws.NewUiSelect(nba) - jaws.NewUiSpan(htmlGetter) - jaws.NewUiTbody(&testContainer{}) - jaws.NewUiTd(htmlGetter) - jaws.NewUiText(jaws.Bind(&mu, &vstring)) - jaws.NewUiTr(htmlGetter) +func (w *errResponseWriter) WriteHeader(statusCode int) { + w.code = statusCode +} + +func (w *errResponseWriter) Write(p []byte) (int, error) { + w.writeCall++ + return 0, w.writeErr +} + +func TestServeHTTP_GetJavascript(t *testing.T) { + jw, _ := New() + go jw.Serve() + defer jw.Close() + + is := newTestHelper(t) + + mux := http.NewServeMux() + mux.Handle("GET /jaws/", jw) + + req := httptest.NewRequest("", jw.serveJS.Name, nil) + req.Header.Add("Accept-Encoding", "blepp") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusOK) + 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) + + req = httptest.NewRequest("", jw.serveJS.Name, nil) + req.Header.Add("Accept-Encoding", "gzip") + w = httptest.NewRecorder() + + mux.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusOK) + is.Equal(w.Header()["Cache-Control"], staticserve.HeaderCacheControl) + is.Equal(w.Header()["Content-Type"], []string{mime.TypeByExtension(".js")}) + is.Equal(w.Header()["Content-Encoding"], headerContentGZip) + + gr, err := gzip.NewReader(w.Body) + is.NoErr(err) + 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(assets.JavascriptText), len(b)) + is.Equal(string(assets.JavascriptText), string(b)) +} + +func TestServeHTTP_GetCSS(t *testing.T) { + jw, _ := New() + go jw.Serve() + defer jw.Close() + + is := newTestHelper(t) + + mux := http.NewServeMux() + mux.Handle("GET /jaws/", jw) + + req := httptest.NewRequest("", jw.serveCSS.Name, nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusOK) + 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")}) +} + +func TestServeHTTP_GetPing(t *testing.T) { + is := newTestHelper(t) + jw, _ := New() + go jw.Serve() + defer jw.Close() + + req := httptest.NewRequest("", "/jaws/.ping", nil) + w := httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) + is.Equal(len(w.Body.Bytes()), 0) + is.Equal(w.Header()["Content-Length"], nil) + is.Equal(w.Code, http.StatusNoContent) + + req = httptest.NewRequest(http.MethodPost, "/jaws/.ping", nil) + w = httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusMethodNotAllowed) + is.Equal(w.Header()["Cache-Control"], nil) + + req = httptest.NewRequest("", "/jaws/.pong", nil) + w = httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusNotFound) + is.Equal(w.Header()["Cache-Control"], nil) + + req = httptest.NewRequest("", "/something_else", nil) + w = httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusNotFound) + is.Equal(w.Header()["Cache-Control"], nil) + + jw.Close() + + req = httptest.NewRequest("", "/jaws/.ping", nil) + w = httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusServiceUnavailable) + is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) +} + +func TestServeHTTP_GetKey(t *testing.T) { + is := newTestHelper(t) + jw, _ := New() + go jw.Serve() + defer jw.Close() + + req := httptest.NewRequest("", "/jaws/", nil) + w := httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusNotFound) + is.Equal(w.Header()["Cache-Control"], nil) + + req = httptest.NewRequest("", "/jaws/12345678", nil) + w = httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusNotFound) + is.Equal(w.Header()["Cache-Control"], nil) + + w = httptest.NewRecorder() + rq := jw.NewRequest(req) + req = httptest.NewRequest("", "/jaws/"+rq.JawsKeyString(), nil) + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusUpgradeRequired) + is.Equal(w.Header()["Cache-Control"], nil) +} + +func TestServeHTTP_Noscript(t *testing.T) { + is := newTestHelper(t) + jw, _ := New() + go jw.Serve() + defer jw.Close() + + w := httptest.NewRecorder() + rq := jw.NewRequest(httptest.NewRequest("", "/", nil)) + req := httptest.NewRequest("", "/jaws/"+rq.JawsKeyString()+"/noscript", nil) + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusNoContent) +} + +func TestServeHTTP_TailScript(t *testing.T) { + is := newTestHelper(t) + NextJid = 0 + jw, _ := New() + go jw.Serve() + defer jw.Close() + + hr := httptest.NewRequest(http.MethodGet, "/", nil) + rq := jw.NewRequest(hr) + item := &testUi{} + e := rq.NewElement(item) + e.SetAttr("title", ``) + e.SetClass("cls") + e.SetInner("kept") + + req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) + req.RemoteAddr = hr.RemoteAddr + w := httptest.NewRecorder() + jw.ServeHTTP(w, req) + + is.Equal(w.Code, http.StatusOK) + is.Equal(w.Header()["Content-Type"], headerContentTypeJavaScript) + is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) + is.Equal(strings.Contains(w.Body.String(), `setAttribute("title","\x3c/script>\x3cimg onerror=alert(1) src=x>");`), true) + is.Equal(strings.Contains(w.Body.String(), `classList?.add("cls");`), true) + is.Equal(strings.Contains(w.Body.String(), "kept"), false) + is.Equal(jw.RequestCount(), 1) +} + +func TestServeHTTP_TailScript_EndpointIsPerRequest(t *testing.T) { + is := newTestHelper(t) + jw, _ := New() + go jw.Serve() + defer jw.Close() + + hr := httptest.NewRequest(http.MethodGet, "/", nil) + rq := jw.NewRequest(hr) + + req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) + req.RemoteAddr = hr.RemoteAddr + w := httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusOK) + + req = httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) + req.RemoteAddr = hr.RemoteAddr + w = httptest.NewRecorder() + jw.ServeHTTP(w, req) + is.Equal(w.Code, http.StatusNoContent) +} + +func TestServeHTTP_TailScript_WriteError(t *testing.T) { + is := newTestHelper(t) + jw, _ := New() + go jw.Serve() + defer jw.Close() + + hr := httptest.NewRequest(http.MethodGet, "/", nil) + rq := jw.NewRequest(hr) + item := &testUi{} + rq.NewElement(item).SetClass("cls") + + req := httptest.NewRequest(http.MethodGet, "/jaws/.tail/"+rq.JawsKeyString(), nil) + req.RemoteAddr = hr.RemoteAddr + w := &errResponseWriter{writeErr: errors.New("write failed")} + jw.ServeHTTP(w, req) + + is.Equal(w.writeCall > 0, true) + is.Equal(w.Header()["Content-Type"], headerContentTypeJavaScript) + is.Equal(w.Header()["Cache-Control"], headerCacheControlNoStore) + is.Equal(jw.RequestCount(), 1) +} + +func TestJaws_Session(t *testing.T) { + NextJid = 0 + rq := newTestRequest(t) + defer rq.Close() + + dot := jtag.Tag("123") + + h := rq.Jaws.Session(rq.Jaws.Handler("testtemplate", dot)) + var buf bytes.Buffer + var rr httptest.ResponseRecorder + rr.Body = &buf + r := httptest.NewRequest("GET", "/", nil) + + if sess := rq.Jaws.GetSession(r); sess != nil { + t.Error("session already exists") + } + + h.ServeHTTP(&rr, r) + if got := buf.String(); got != `
    123
    ` { + t.Error(got) + } + + sess := rq.Jaws.GetSession(r) + if sess == nil { + t.Error("no session") + } } diff --git a/jawsboot/README.md b/jawsboot/README.md index 009a1cc3..e07b0c4f 100644 --- a/jawsboot/README.md +++ b/jawsboot/README.md @@ -16,9 +16,10 @@ import ( "github.com/linkdata/jaws" "github.com/linkdata/jaws/jawsboot" - "github.com/linkdata/jaws/staticserve" - "github.com/linkdata/jaws/templatereloader" - "github.com/linkdata/jaws/ui" + "github.com/linkdata/jaws/lib/bind" + "github.com/linkdata/jaws/lib/templatereloader" + "github.com/linkdata/jaws/lib/ui" + "github.com/linkdata/staticserve" ) //go:embed assets @@ -31,7 +32,7 @@ func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) { jw.AddTemplateLookuper(tmpl) // Initialize jawsboot, we will serve the Javascript and CSS from /static/*.[js|css] // All files under assets/static will be available under /static. Any favicon loaded - // this way will have its URL available using jaws.FaviconURL(). + // this way will have its URL available using jw.FaviconURL(). if err = jw.Setup(mux.Handle, "/static", jawsboot.Setup, staticserve.MustNewFS(assetsFS, "assets/static", "images/favicon.png"), @@ -39,7 +40,7 @@ func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) { // Add a route to our index template with a bound variable accessible as '.Dot' in the template var mu sync.Mutex var f float64 - mux.Handle("GET /", ui.Handler(jw, "index.html", jaws.Bind(&mu, &f))) + mux.Handle("GET /", ui.Handler(jw, "index.html", bind.New(&mu, &f))) } } return diff --git a/jawsboot/example_test.go b/jawsboot/example_test.go index 54a2c5db..60d6d7ef 100644 --- a/jawsboot/example_test.go +++ b/jawsboot/example_test.go @@ -8,9 +8,10 @@ import ( "github.com/linkdata/jaws" "github.com/linkdata/jaws/jawsboot" - "github.com/linkdata/jaws/staticserve" - "github.com/linkdata/jaws/templatereloader" - "github.com/linkdata/jaws/ui" + "github.com/linkdata/jaws/lib/bind" + "github.com/linkdata/jaws/lib/templatereloader" + "github.com/linkdata/jaws/lib/ui" + "github.com/linkdata/staticserve" ) // This example assumes an 'assets' directory: @@ -40,7 +41,7 @@ func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) { // Add a route to our index template with a bound variable accessible as '.Dot' in the template var mu sync.Mutex var f float64 - mux.Handle("GET /", ui.Handler(jw, "index.html", jaws.Bind(&mu, &f))) + mux.Handle("GET /", ui.Handler(jw, "index.html", bind.New(&mu, &f))) } } return diff --git a/jawsboot/jawsboot.go b/jawsboot/jawsboot.go index 19a60830..bc5322c1 100644 --- a/jawsboot/jawsboot.go +++ b/jawsboot/jawsboot.go @@ -8,7 +8,7 @@ import ( "path" "github.com/linkdata/jaws" - "github.com/linkdata/jaws/staticserve" + "github.com/linkdata/staticserve" ) //go:embed assets diff --git a/jawsboot/jawsboot_test.go b/jawsboot/jawsboot_test.go index 07083753..383cab50 100644 --- a/jawsboot/jawsboot_test.go +++ b/jawsboot/jawsboot_test.go @@ -2,96 +2,27 @@ package jawsboot_test import ( "bytes" - "compress/gzip" "embed" "io" - "io/fs" "net/http" "net/http/httptest" "path" - "sort" "strconv" "strings" "testing" "github.com/linkdata/jaws" "github.com/linkdata/jaws/jawsboot" - "github.com/linkdata/jaws/staticserve" - "github.com/linkdata/jaws/ui" + "github.com/linkdata/jaws/lib/ui" + "github.com/linkdata/staticserve" ) //go:embed assets var testAssetsFS embed.FS -type expectedAsset struct { - Filepath string - URI string - Plain []byte - SS *staticserve.StaticServe -} - -func readGzip(t *testing.T, b []byte) []byte { - t.Helper() - gzr, err := gzip.NewReader(bytes.NewReader(b)) - if err != nil { - t.Fatal(err) - } - plain, err := io.ReadAll(gzr) - if cerr := gzr.Close(); cerr != nil && err == nil { - err = cerr - } - if err != nil { - t.Fatal(err) - } - return plain -} - -func expectedAssets(t *testing.T, prefix string) (expected []expectedAsset) { - t.Helper() - var filepaths []string - err := fs.WalkDir(testAssetsFS, "assets/static", func(pathname string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - filepaths = append(filepaths, strings.TrimPrefix(pathname, "assets/static/")) - return nil - }) - if err != nil { - t.Fatal(err) - } - sort.Strings(filepaths) - if len(filepaths) == 0 { - t.Fatal("expected at least one static asset") - } - for _, filepath := range filepaths { - b, err := fs.ReadFile(testAssetsFS, path.Join("assets/static", filepath)) - if err != nil { - t.Fatal(err) - } - ss, err := staticserve.New(filepath, b) - if err != nil { - t.Fatal(err) - } - plain := b - if strings.HasSuffix(filepath, ".gz") { - plain = readGzip(t, b) - } - expected = append(expected, expectedAsset{ - Filepath: filepath, - URI: path.Join(prefix, ss.Name), - Plain: plain, - SS: ss, - }) - } - return -} - func TestJawsBoot_Setup(t *testing.T) { const prefix = "/static" - expected := expectedAssets(t, prefix) + expected := expectedStaticAssets(t, testAssetsFS, "assets/static", prefix) mux := http.NewServeMux() jw, _ := jaws.New() @@ -110,8 +41,8 @@ func TestJawsBoot_Setup(t *testing.T) { t.Error(txt) } for _, exp := range expected { - if !strings.Contains(txt, `"`+exp.URI+`"`) { - t.Errorf("expected head html to include %q", exp.URI) + if !strings.Contains(txt, `"`+exp.uri+`"`) { + t.Errorf("expected head html to include %q", exp.uri) } } if !strings.Contains(txt, "\"/other/foobar.js\"") { @@ -119,69 +50,69 @@ func TestJawsBoot_Setup(t *testing.T) { } for _, exp := range expected { - rq := httptest.NewRequest(http.MethodGet, exp.URI, nil) + rq := httptest.NewRequest(http.MethodGet, exp.uri, nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, rq) res := rr.Result() if sc := res.StatusCode; sc != http.StatusOK { - t.Errorf("%q plain: expected status %d, got %d", exp.Filepath, http.StatusOK, sc) + t.Errorf("%q plain: expected status %d, got %d", exp.filepath, http.StatusOK, sc) } if cc := res.Header.Get("Cache-Control"); cc != staticserve.HeaderCacheControl[0] { - t.Errorf("%q plain: expected cache-control %q, got %q", exp.Filepath, staticserve.HeaderCacheControl[0], cc) + t.Errorf("%q plain: expected cache-control %q, got %q", exp.filepath, staticserve.HeaderCacheControl[0], cc) } if vary := res.Header.Get("Vary"); vary != staticserve.HeaderVary[0] { - t.Errorf("%q plain: expected vary %q, got %q", exp.Filepath, staticserve.HeaderVary[0], vary) + t.Errorf("%q plain: expected vary %q, got %q", exp.filepath, staticserve.HeaderVary[0], vary) } if ce := res.Header.Get("Content-Encoding"); ce != "" { - t.Errorf("%q plain: expected empty content-encoding, got %q", exp.Filepath, ce) + t.Errorf("%q plain: expected empty content-encoding, got %q", exp.filepath, ce) } - if ct := res.Header.Get("Content-Type"); ct != exp.SS.ContentType { - t.Errorf("%q plain: expected content type %q, got %q", exp.Filepath, exp.SS.ContentType, ct) + if ct := res.Header.Get("Content-Type"); ct != exp.ss.ContentType { + t.Errorf("%q plain: expected content type %q, got %q", exp.filepath, exp.ss.ContentType, ct) } b, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } - if !bytes.Equal(b, exp.Plain) { - t.Errorf("%q plain: body mismatch", exp.Filepath) + if !bytes.Equal(b, exp.plain) { + t.Errorf("%q plain: body mismatch", exp.filepath) } if err = res.Body.Close(); err != nil { t.Fatal(err) } - rq = httptest.NewRequest(http.MethodGet, exp.URI, nil) + rq = httptest.NewRequest(http.MethodGet, exp.uri, nil) rq.Header.Set("Accept-Encoding", "gzip") rr = httptest.NewRecorder() mux.ServeHTTP(rr, rq) res = rr.Result() if sc := res.StatusCode; sc != http.StatusOK { - t.Errorf("%q gzip: expected status %d, got %d", exp.Filepath, http.StatusOK, sc) + t.Errorf("%q gzip: expected status %d, got %d", exp.filepath, http.StatusOK, sc) } if cc := res.Header.Get("Cache-Control"); cc != staticserve.HeaderCacheControl[0] { - t.Errorf("%q gzip: expected cache-control %q, got %q", exp.Filepath, staticserve.HeaderCacheControl[0], cc) + t.Errorf("%q gzip: expected cache-control %q, got %q", exp.filepath, staticserve.HeaderCacheControl[0], cc) } if vary := res.Header.Get("Vary"); vary != staticserve.HeaderVary[0] { - t.Errorf("%q gzip: expected vary %q, got %q", exp.Filepath, staticserve.HeaderVary[0], vary) + t.Errorf("%q gzip: expected vary %q, got %q", exp.filepath, staticserve.HeaderVary[0], vary) } if ce := res.Header.Get("Content-Encoding"); ce != "gzip" { - t.Errorf("%q gzip: expected content-encoding %q, got %q", exp.Filepath, "gzip", ce) + t.Errorf("%q gzip: expected content-encoding %q, got %q", exp.filepath, "gzip", ce) } - if cl := res.Header.Get("Content-Length"); cl != strconv.Itoa(len(exp.SS.Gz)) { - t.Errorf("%q gzip: expected content-length %d, got %q", exp.Filepath, len(exp.SS.Gz), cl) + if cl := res.Header.Get("Content-Length"); cl != strconv.Itoa(len(exp.ss.Gz)) { + t.Errorf("%q gzip: expected content-length %d, got %q", exp.filepath, len(exp.ss.Gz), cl) } - if ct := res.Header.Get("Content-Type"); ct != exp.SS.ContentType { - t.Errorf("%q gzip: expected content type %q, got %q", exp.Filepath, exp.SS.ContentType, ct) + if ct := res.Header.Get("Content-Type"); ct != exp.ss.ContentType { + t.Errorf("%q gzip: expected content type %q, got %q", exp.filepath, exp.ss.ContentType, ct) } b, err = io.ReadAll(res.Body) if err != nil { t.Fatal(err) } - if !bytes.Equal(b, exp.SS.Gz) { - t.Errorf("%q gzip: body mismatch", exp.Filepath) + if !bytes.Equal(b, exp.ss.Gz) { + t.Errorf("%q gzip: body mismatch", exp.filepath) } - if unpacked := readGzip(t, b); !bytes.Equal(unpacked, exp.Plain) { - t.Errorf("%q gzip: unpacked body mismatch", exp.Filepath) + if unpacked := readGzip(t, b); !bytes.Equal(unpacked, exp.plain) { + t.Errorf("%q gzip: unpacked body mismatch", exp.filepath) } if err = res.Body.Close(); err != nil { t.Fatal(err) diff --git a/jawsboot/testsupport_test.go b/jawsboot/testsupport_test.go new file mode 100644 index 00000000..3b043989 --- /dev/null +++ b/jawsboot/testsupport_test.go @@ -0,0 +1,81 @@ +package jawsboot_test + +import ( + "bytes" + "compress/gzip" + "io" + "io/fs" + "path" + "sort" + "strings" + "testing" + + "github.com/linkdata/staticserve" +) + +type expectedStaticAsset struct { + filepath string + uri string + plain []byte + ss *staticserve.StaticServe +} + +func readGzip(t *testing.T, b []byte) []byte { + t.Helper() + gzr, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + plain, err := io.ReadAll(gzr) + if cerr := gzr.Close(); cerr != nil && err == nil { + err = cerr + } + if err != nil { + t.Fatal(err) + } + return plain +} + +func expectedStaticAssets(t *testing.T, fsys fs.FS, root, uriPrefix string) (expected []expectedStaticAsset) { + t.Helper() + var filepaths []string + root = path.Clean(root) + err := fs.WalkDir(fsys, root, func(pathname string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + filepaths = append(filepaths, strings.TrimPrefix(pathname, root+"/")) + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(filepaths) + if len(filepaths) == 0 { + t.Fatal("expected at least one asset file") + } + for _, filepath := range filepaths { + b, err := fs.ReadFile(fsys, path.Join(root, filepath)) + if err != nil { + t.Fatal(err) + } + ss, err := staticserve.New(filepath, b) + if err != nil { + t.Fatal(err) + } + plain := b + if strings.HasSuffix(filepath, ".gz") { + plain = readGzip(t, b) + } + expected = append(expected, expectedStaticAsset{ + filepath: filepath, + uri: path.Join(uriPrefix, ss.Name), + plain: plain, + ss: ss, + }) + } + return expected +} diff --git a/jawstest/README.md b/jawstest/README.md deleted file mode 100644 index 60cb5ebc..00000000 --- a/jawstest/README.md +++ /dev/null @@ -1 +0,0 @@ -Integration tests \ No newline at end of file diff --git a/jawstest/handler_test.go b/jawstest/handler_test.go deleted file mode 100644 index 1a6ff7a1..00000000 --- a/jawstest/handler_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package jawstest - -import ( - "bytes" - "net/http/httptest" - "testing" - - "github.com/linkdata/jaws" - core "github.com/linkdata/jaws/core" - "github.com/linkdata/jaws/ui" -) - -func TestHandler_ServeHTTP(t *testing.T) { - core.NextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - dot := jaws.Tag("123") - h := ui.Handler(rq.TestRequest.Request.Jaws, "testtemplate", dot) - var buf bytes.Buffer - var rr httptest.ResponseRecorder - rr.Body = &buf - r := httptest.NewRequest("GET", "/", nil) - h.ServeHTTP(&rr, r) - if got := buf.String(); got != `
    123
    ` { - t.Error(got) - } -} diff --git a/jawstest/jawsjaws_test.go b/jawstest/jawsjaws_test.go deleted file mode 100644 index b87fc92b..00000000 --- a/jawstest/jawsjaws_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package jawstest - -import ( - "html/template" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/linkdata/jaws" -) - -func TestJaws_RequestLifecycle(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) - if rq == nil { - t.Fatal("nil request") - } - if jw.RequestCount() != 1 { - t.Fatalf("unexpected request count: %d", jw.RequestCount()) - } - - if got := jw.UseRequest(0, hr); got != nil { - t.Fatal("expected nil for invalid key") - } - if got := jw.UseRequest(rq.JawsKey, hr); got != rq { - t.Fatal("expected claimed request") - } - if got := jw.UseRequest(rq.JawsKey, hr); got != nil { - t.Fatal("expected nil for already-claimed request") - } -} - -func TestJaws_TemplateLookupers(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - - tmpl := template.Must(template.New("it").Parse(`ok`)) - jw.AddTemplateLookuper(tmpl) - if got := jw.LookupTemplate("it"); got == nil { - t.Fatal("expected template") - } - jw.RemoveTemplateLookuper(tmpl) - if got := jw.LookupTemplate("it"); got != nil { - t.Fatal("expected template removed") - } -} - -func TestNewTestRequestHarness(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - go jw.Serve() - - rq := NewTestRequest(jw, nil) - if rq == nil { - t.Fatal("nil test request") - } - defer rq.Close() - - if rq.Initial() == nil { - t.Fatal("expected initial request") - } - - if err := rq.Template("missingtemplate", nil); err == nil { - t.Fatal("expected missing template error") - } -} - -func TestRequestWriterHelpersFromTemplate(t *testing.T) { - tj := newTestJaws() - defer tj.Close() - - tj.AddTemplateLookuper(template.Must(template.New("rwhelper").Parse(`{{$.Span "ok"}}`))) - rq := tj.newRequest(nil) - defer rq.Close() - - if err := rq.Template("rwhelper", nil); err != nil { - t.Fatal(err) - } - if got := rq.BodyString(); !strings.Contains(got, `{{.}}{{end}}`)) - tj.AddTemplateLookuper(tj.testtmpl) - go tj.Serve() - return -} - -func (tj *testJaws) newRequest(hr *http.Request) (tr *TestRequest) { - return NewTestRequest(tj.Jaws, hr) -} - -func newTestRequest(t *testing.T) (tr *TestRequest) { - tj := newTestJaws() - if t != nil { - t.Helper() - t.Cleanup(tj.Close) - } - return NewTestRequest(tj.Jaws, nil) -} - -type testAuth struct{} - -func (testAuth) Data() map[string]any { return nil } -func (testAuth) Email() string { return "" } -func (testAuth) IsAdmin() bool { return true } diff --git a/jawstest/testrequest.go b/jawstest/testrequest.go deleted file mode 100644 index fbf521b9..00000000 --- a/jawstest/testrequest.go +++ /dev/null @@ -1,42 +0,0 @@ -package jawstest - -import ( - "net/http" - - "github.com/linkdata/jaws" - "github.com/linkdata/jaws/ui" -) - -// TestRequest wraps jaws.TestRequest with ui.RequestWriter helpers. -type TestRequest struct { - *jaws.TestRequest - rw ui.RequestWriter -} - -// NewTestRequest forwards to jaws.NewTestRequest. -func NewTestRequest(jw *jaws.Jaws, hr *http.Request) *TestRequest { - tr := jaws.NewTestRequest(jw, hr) - return &TestRequest{ - TestRequest: tr, - rw: ui.RequestWriter{ - Request: tr.Request, - Writer: tr.ResponseRecorder, - }, - } -} - -func (tr *TestRequest) UI(widget jaws.UI, params ...any) error { - return tr.rw.UI(widget, params...) -} - -func (tr *TestRequest) Template(name string, dot any, params ...any) error { - return tr.rw.Template(name, dot, params...) -} - -func (tr *TestRequest) JsVar(name string, jsvar any, params ...any) error { - return tr.rw.JsVar(name, jsvar, params...) -} - -func (tr *TestRequest) Register(updater jaws.Updater, params ...any) jaws.Jid { - return tr.rw.Register(updater, params...) -} diff --git a/jawstest/testrequest_test.go b/jawstest/testrequest_test.go deleted file mode 100644 index 0ef863c2..00000000 --- a/jawstest/testrequest_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package jawstest - -import ( - "strings" - "testing" - - "github.com/linkdata/jaws" - "github.com/linkdata/jaws/ui" -) - -type testRequestUpdater struct { - called int -} - -func (u *testRequestUpdater) JawsUpdate(*jaws.Element) { - u.called++ -} - -func TestTestRequest_WrapperUIAndRegister(t *testing.T) { - tj := newTestJaws() - defer tj.Close() - - rq := tj.newRequest(nil) - defer rq.Close() - - if err := rq.UI(ui.NewSpan(jaws.MakeHTMLGetter("ok"))); err != nil { - t.Fatal(err) - } - if got := rq.BodyString(); !strings.Contains(got, `"), 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 jawswire.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/bind.go b/lib/bind/bind.go similarity index 51% rename from core/bind.go rename to lib/bind/bind.go index 5b3764bb..6f0cad3c 100644 --- a/core/bind.go +++ b/lib/bind/bind.go @@ -1,13 +1,13 @@ -package jaws +package bind import ( "sync" ) -// Bind returns a Binder[T] with the given sync.Locker (or RWLocker) and a pointer to the underlying value of type T. +// New returns a Binder[T] with the given sync.Locker (or RWLocker) and a pointer to the underlying value of type T. // // The pointer will be used as the UI tag. -func Bind[T comparable](l sync.Locker, p *T) Binder[T] { +func New[T comparable](l sync.Locker, p *T) Binder[T] { if rl, ok := l.(RWLocker); ok { return binding[T]{RWLocker: rl, ptr: p} } diff --git a/core/bind_test.go b/lib/bind/bind_test.go similarity index 75% rename from core/bind_test.go rename to lib/bind/bind_test.go index 9670ae5d..8e74a3db 100644 --- a/core/bind_test.go +++ b/lib/bind/bind_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "errors" @@ -8,6 +8,8 @@ import ( "time" "github.com/linkdata/deadlock" + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) func TestBind_Hook_Success_panic(t *testing.T) { @@ -18,7 +20,7 @@ func TestBind_Hook_Success_panic(t *testing.T) { }() var mu deadlock.Mutex var val string - Bind(&mu, &val).Success(func(n int) {}) + New(&mu, &val).Success(func(n int) {}) t.Fail() } @@ -29,7 +31,7 @@ func TestBind_Hook_Success_breaksonerr(t *testing.T) { calls1 := 0 calls2 := 0 calls3 := 0 - bind1 := Bind(&mu, &val). + bind1 := New(&mu, &val). Success(func() { calls1++ }). @@ -60,7 +62,7 @@ func testBind_Hook_Success[T comparable](t *testing.T, testval T) { var blankval T calls1 := 0 - bind1 := Bind(&mu, &val). + bind1 := New(&mu, &val). Success(func() { calls1++ }) @@ -70,13 +72,13 @@ func testBind_Hook_Success[T comparable](t *testing.T, testval T) { if x := bind1.JawsGet(nil); x != testval { t.Error(x) } - if err := bind1.JawsSet(nil, testval); err != ErrValueUnchanged { + if err := bind1.JawsSet(nil, testval); err != jaws.ErrValueUnchanged { t.Error(err) } if calls1 != 1 { t.Error(calls1) } - tags1 := MustTagExpand(nil, bind1) + tags1 := jtag.MustTagExpand(nil, bind1) if !reflect.DeepEqual(tags1, []any{&val}) { t.Error(tags1) } @@ -99,14 +101,14 @@ func testBind_Hook_Success[T comparable](t *testing.T, testval T) { if calls2 != 1 { t.Error(calls2) } - tags2 := MustTagExpand(nil, bind2) + tags2 := jtag.MustTagExpand(nil, bind2) if !reflect.DeepEqual(tags2, []any{&val}) { t.Error(tags2) } calls3 := 0 bind3 := bind2. - Success(func(*Element) { + Success(func(*jaws.Element) { calls3++ if calls2 <= calls3 { t.Error(calls2, calls3) @@ -127,7 +129,7 @@ func testBind_Hook_Success[T comparable](t *testing.T, testval T) { calls4 := 0 bind4 := bind3. - Success(func(*Element) (err error) { + Success(func(*jaws.Element) (err error) { calls4++ if calls3 <= calls4 { t.Error(calls3, calls4) @@ -156,8 +158,8 @@ func testBind_Hook_Set[T comparable](t *testing.T, testval T) { var val T calls1 := 0 - bind1 := Bind(&mu, &val). - SetLocked(func(bind Binder[T], elem *Element, value T) (err error) { + bind1 := New(&mu, &val). + SetLocked(func(bind Binder[T], elem *jaws.Element, value T) (err error) { calls1++ return bind.JawsSetLocked(elem, value) }) @@ -167,20 +169,20 @@ func testBind_Hook_Set[T comparable](t *testing.T, testval T) { if x := bind1.JawsGet(nil); x != testval { t.Error(x) } - if err := bind1.JawsSet(nil, testval); err != ErrValueUnchanged { + if err := bind1.JawsSet(nil, testval); err != jaws.ErrValueUnchanged { t.Error(err) } if calls1 != 2 { t.Error(calls1) } - tags1 := MustTagExpand(nil, bind1) + tags1 := jtag.MustTagExpand(nil, bind1) if !reflect.DeepEqual(tags1, []any{&val}) { t.Error(tags1) } calls2 := 0 bind2 := bind1. - SetLocked(func(bind Binder[T], elem *Element, value T) (err error) { + SetLocked(func(bind Binder[T], elem *jaws.Element, value T) (err error) { calls2++ return bind.JawsSetLocked(elem, value) }) @@ -191,7 +193,7 @@ func testBind_Hook_Set[T comparable](t *testing.T, testval T) { if calls2 != 0 { t.Error(calls2) } - tags2 := MustTagExpand(nil, bind2) + tags2 := jtag.MustTagExpand(nil, bind2) if !reflect.DeepEqual(tags2, []any{&val}) { t.Error(tags2) } @@ -202,8 +204,8 @@ func testBind_Hook_Get[T comparable](t *testing.T, testval T) { var val T calls1 := 0 - bind1 := Bind(&mu, &val). - GetLocked(func(bind Binder[T], elem *Element) (value T) { + bind1 := New(&mu, &val). + GetLocked(func(bind Binder[T], elem *jaws.Element) (value T) { calls1++ return bind.JawsGetLocked(elem) }) @@ -213,20 +215,20 @@ func testBind_Hook_Get[T comparable](t *testing.T, testval T) { if x := bind1.JawsGet(nil); x != testval { t.Error(x) } - if err := bind1.JawsSet(nil, testval); err != ErrValueUnchanged { + if err := bind1.JawsSet(nil, testval); err != jaws.ErrValueUnchanged { t.Error(err) } if calls1 != 1 { t.Error(calls1) } - tags1 := MustTagExpand(nil, bind1) + tags1 := jtag.MustTagExpand(nil, bind1) if !reflect.DeepEqual(tags1, []any{&val}) { t.Error(tags1) } calls2 := 0 bind2 := bind1. - GetLocked(func(bind Binder[T], elem *Element) (value T) { + GetLocked(func(bind Binder[T], elem *jaws.Element) (value T) { calls2++ return bind.JawsGetLocked(elem) }) @@ -240,7 +242,7 @@ func testBind_Hook_Get[T comparable](t *testing.T, testval T) { if calls2 != 0 { t.Error(calls2) } - tags2 := MustTagExpand(nil, bind2) + tags2 := jtag.MustTagExpand(nil, bind2) if !reflect.DeepEqual(tags2, []any{&val}) { t.Error(tags2) } @@ -251,22 +253,22 @@ func TestBind_Hook_Clicked_binding(t *testing.T) { var val string calls := 0 - gotElem := &Element{} + gotElem := &jaws.Element{} gotName := "" - bind := Bind(&mu, &val). - Clicked(func(bind Binder[string], elem *Element, name string) (err error) { + bind := New(&mu, &val). + Clicked(func(bind Binder[string], elem *jaws.Element, name string) (err error) { calls++ gotElem = elem gotName = name return nil }) - handler, ok := bind.(ClickHandler) + handler, ok := bind.(jaws.ClickHandler) if !ok { t.Fatalf("%T does not implement ClickHandler", bind) } - elem := &Element{} + elem := &jaws.Element{} if err := handler.JawsClick(elem, "save"); err != nil { t.Fatal(err) } @@ -279,7 +281,7 @@ func TestBind_Hook_Clicked_binding(t *testing.T) { if gotName != "save" { t.Error(gotName) } - tags := MustTagExpand(nil, bind) + tags := jtag.MustTagExpand(nil, bind) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -290,26 +292,26 @@ func TestBind_Hook_Clicked_bindingHook(t *testing.T) { var val string successCalls := 0 - bindWithSuccess := Bind(&mu, &val).Success(func() { + bindWithSuccess := New(&mu, &val).Success(func() { successCalls++ }) clickCalls1 := 0 clickCalls2 := 0 - clickBind1 := bindWithSuccess.Clicked(func(Binder[string], *Element, string) error { + clickBind1 := bindWithSuccess.Clicked(func(Binder[string], *jaws.Element, string) error { clickCalls1++ return nil }) - clickBind2 := clickBind1.Clicked(func(Binder[string], *Element, string) error { + clickBind2 := clickBind1.Clicked(func(Binder[string], *jaws.Element, string) error { clickCalls2++ return nil }) - handler1, ok := clickBind1.(ClickHandler) + handler1, ok := clickBind1.(jaws.ClickHandler) if !ok { t.Fatalf("%T does not implement ClickHandler", clickBind1) } - handler2, ok := clickBind2.(ClickHandler) + handler2, ok := clickBind2.(jaws.ClickHandler) if !ok { t.Fatalf("%T does not implement ClickHandler", clickBind2) } @@ -343,10 +345,10 @@ func TestBind_Hook_Clicked_bindingHook(t *testing.T) { if got := clickBind2.JawsGet(nil); got != "foo" { t.Error(got) } - if err := bindWithSuccess.(ClickHandler).JawsClick(nil, "x"); !errors.Is(err, ErrEventUnhandled) { + if err := bindWithSuccess.(jaws.ClickHandler).JawsClick(nil, "x"); !errors.Is(err, jaws.ErrEventUnhandled) { t.Fatal(err) } - tags := MustTagExpand(nil, clickBind2) + tags := jtag.MustTagExpand(nil, clickBind2) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -359,19 +361,19 @@ func TestBind_Hook_Clicked_bindingHook_fallsThroughUnhandled(t *testing.T) { order := []int{} clickCalls1 := 0 clickCalls2 := 0 - clickBind2 := Bind(&mu, &val). - Clicked(func(Binder[string], *Element, string) error { + clickBind2 := New(&mu, &val). + Clicked(func(Binder[string], *jaws.Element, string) error { clickCalls1++ order = append(order, 1) - return ErrEventUnhandled + return jaws.ErrEventUnhandled }). - Clicked(func(Binder[string], *Element, string) error { + Clicked(func(Binder[string], *jaws.Element, string) error { clickCalls2++ order = append(order, 2) return nil }) - handler, ok := clickBind2.(ClickHandler) + handler, ok := clickBind2.(jaws.ClickHandler) if !ok { t.Fatalf("%T does not implement ClickHandler", clickBind2) } @@ -393,12 +395,12 @@ func TestBind_Click_defaultUnhandled(t *testing.T) { var mu deadlock.Mutex var val string - bind := Bind(&mu, &val) - handler, ok := bind.(ClickHandler) + bind := New(&mu, &val) + handler, ok := bind.(jaws.ClickHandler) if !ok { t.Fatalf("%T does not implement ClickHandler", bind) } - if err := handler.JawsClick(nil, "ignored"); !errors.Is(err, ErrEventUnhandled) { + if err := handler.JawsClick(nil, "ignored"); !errors.Is(err, jaws.ErrEventUnhandled) { t.Fatal(err) } } @@ -424,8 +426,8 @@ func TestBindFunc_String(t *testing.T) { var val string testBind_Hooks(t, "foo") - testBind_StringSetter(t, Bind(&mu, &val)) - testBind_StringSetter(t, Bind(&mu, &val).Success(func() {})) + testBind_StringSetter(t, New(&mu, &val)) + testBind_StringSetter(t, New(&mu, &val).Success(func() {})) } func testBind_FloatSetter(t *testing.T, v Setter[float64]) { @@ -443,8 +445,8 @@ func TestBindFunc_Float(t *testing.T) { var val float64 testBind_Hooks(t, float64(1.23)) - testBind_FloatSetter(t, Bind(&mu, &val)) - testBind_FloatSetter(t, Bind(&mu, &val).Success(func() {})) + testBind_FloatSetter(t, New(&mu, &val)) + testBind_FloatSetter(t, New(&mu, &val).Success(func() {})) } func testBind_BoolSetter(t *testing.T, v Setter[bool]) { @@ -472,8 +474,8 @@ func TestBindFunc_Bool(t *testing.T) { var val bool testBind_Hooks(t, true) - testBind_BoolSetter(t, Bind(&mu, &val)) - testBind_BoolSetter(t, Bind(&mu, &val).Success(func() {})) + testBind_BoolSetter(t, New(&mu, &val)) + testBind_BoolSetter(t, New(&mu, &val).Success(func() {})) } func testBind_TimeSetter(t *testing.T, v Setter[time.Time]) { @@ -491,15 +493,15 @@ func TestBindFunc_Time(t *testing.T) { var val time.Time testBind_Hooks(t, time.Now()) - testBind_TimeSetter(t, Bind(&mu, &val)) - testBind_TimeSetter(t, Bind(&mu, &val).Success(func() {})) + testBind_TimeSetter(t, New(&mu, &val)) + testBind_TimeSetter(t, New(&mu, &val).Success(func() {})) } func TestBindFormat(t *testing.T) { var mu deadlock.Mutex val := 12 - bind := Bind(&mu, &val) + bind := New(&mu, &val) if v := MakeHTMLGetter(bind).JawsGetHTML(nil); v != "12" { t.Errorf("%T %#v", v, v) } @@ -508,7 +510,7 @@ func TestBindFormat(t *testing.T) { if s := getter.JawsGet(nil); s != " 12" { t.Errorf("%q", s) } - tags := MustTagExpand(nil, getter) + tags := jtag.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -518,7 +520,7 @@ func TestBindFormat(t *testing.T) { if s := getter.JawsGet(nil); s != " 12" { t.Errorf("%q", s) } - tags = MustTagExpand(nil, getter) + tags = jtag.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -528,7 +530,7 @@ func TestBindFormatHTML(t *testing.T) { var mu deadlock.Mutex val := "" - bind := Bind(&mu, &val) + bind := New(&mu, &val) if s := MakeHTMLGetter(bind).JawsGetHTML(nil); s != "<span>" { t.Errorf("%q", s) } @@ -537,7 +539,7 @@ func TestBindFormatHTML(t *testing.T) { if s := getter.JawsGetHTML(nil); s != "" { t.Errorf("%q", s) } - tags := MustTagExpand(nil, getter) + tags := jtag.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } @@ -550,7 +552,7 @@ func TestBindFormatHTML(t *testing.T) { if s := getter.JawsGetHTML(nil); s != "\"\"" { t.Errorf("%q", s) } - tags = MustTagExpand(nil, getter) + tags = jtag.MustTagExpand(nil, getter) if !reflect.DeepEqual(tags, []any{&val}) { t.Error(tags) } diff --git a/core/binder.go b/lib/bind/binder.go similarity index 84% rename from core/binder.go rename to lib/bind/binder.go index fed2b54b..7792510a 100644 --- a/core/binder.go +++ b/lib/bind/binder.go @@ -1,4 +1,9 @@ -package jaws +package bind + +import ( + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" +) // BindSetHook is a function that replaces JawsSetLocked for a Binder. // @@ -7,7 +12,7 @@ package jaws // // The bind argument is the previous Binder in the chain, and you probably // want to call it's JawsSetLocked first. -type BindSetHook[T comparable] func(bind Binder[T], elem *Element, value T) (err error) +type BindSetHook[T comparable] func(bind Binder[T], elem *jaws.Element, value T) (err error) // BindGetHook is a function that replaces JawsGetLocked for a Binder. // @@ -16,12 +21,12 @@ type BindSetHook[T comparable] func(bind Binder[T], elem *Element, value T) (err // // The bind argument is the previous Binder in the chain, and you probably // want to call it's JawsGetLocked first. -type BindGetHook[T comparable] func(bind Binder[T], elem *Element) (value T) +type BindGetHook[T comparable] func(bind Binder[T], elem *jaws.Element) (value T) // BindClickedHook is a function to call when a click event is received. // // The Binder locks are not held when the function is called. -type BindClickedHook[T comparable] func(bind Binder[T], elem *Element, name string) (err error) +type BindClickedHook[T comparable] func(bind Binder[T], elem *jaws.Element, name string) (err error) // BindSuccessHook is a function to call when a call to JawsSet returns with no error. // @@ -30,7 +35,7 @@ type BindClickedHook[T comparable] func(bind Binder[T], elem *Element, name stri // Success hooks in a Binder chain are called in the order they were registered. // If one of them returns an error, that error is returned from JawsSet and // no more success hooks are called. -type BindSuccessHook func(*Element) (err error) +type BindSuccessHook func(*jaws.Element) (err error) type Formatter interface { // Format returns a Getter[string] using fmt.Sprintf(f, JawsGet[T](elem)) @@ -40,13 +45,13 @@ type Formatter interface { type Binder[T comparable] interface { RWLocker Setter[T] - TagGetter + jtag.TagGetter Formatter - ClickHandler + jaws.ClickHandler JawsBinderPrev() Binder[T] // returns the previous Binder in the chain, or nil - JawsGetLocked(elem *Element) (value T) - JawsSetLocked(elem *Element, value T) (err error) + JawsGetLocked(elem *jaws.Element) (value T) + JawsSetLocked(elem *jaws.Element, value T) (err error) // SetLocked returns a Binder[T] that will call fn instead of JawsSetLocked. // diff --git a/core/binding.go b/lib/bind/binding.go similarity index 76% rename from core/binding.go rename to lib/bind/binding.go index 601d3139..8f5052b0 100644 --- a/core/binding.go +++ b/lib/bind/binding.go @@ -1,8 +1,11 @@ -package jaws +package bind import ( "fmt" "html/template" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) type binding[T comparable] struct { @@ -14,38 +17,38 @@ func (bind binding[T]) JawsBinderPrev() Binder[T] { return nil } -func (bind binding[T]) JawsGetLocked(*Element) T { +func (bind binding[T]) JawsGetLocked(*jaws.Element) T { return *bind.ptr } -func (bind binding[T]) JawsGet(elem *Element) (value T) { +func (bind binding[T]) JawsGet(elem *jaws.Element) (value T) { bind.RWLocker.RLock() value = bind.JawsGetLocked(elem) bind.RWLocker.RUnlock() return } -func (bind binding[T]) JawsSetLocked(elem *Element, value T) (err error) { +func (bind binding[T]) JawsSetLocked(elem *jaws.Element, value T) (err error) { if value != *bind.ptr { *bind.ptr = value return nil } - return ErrValueUnchanged + return jaws.ErrValueUnchanged } -func (bind binding[T]) JawsSet(elem *Element, value T) (err error) { +func (bind binding[T]) JawsSet(elem *jaws.Element, value T) (err error) { bind.RWLocker.Lock() err = bind.JawsSetLocked(elem, value) bind.RWLocker.Unlock() return } -func (bind binding[T]) JawsGetTag(*Request) any { +func (bind binding[T]) JawsGetTag(jtag.Context) any { return bind.ptr } -func (bind binding[T]) JawsClick(*Element, string) error { - return ErrEventUnhandled +func (bind binding[T]) JawsClick(*jaws.Element, string) error { + return jaws.ErrEventUnhandled } // SetLocked returns a Binder[T] that will call fn instead of JawsSetLocked. @@ -104,13 +107,13 @@ func (bind binding[T]) Success(fn any) Binder[T] { // Format returns a Getter[string] using fmt.Sprintf(f, JawsGet[T](elem)) func (bind binding[T]) Format(f string) (getter Getter[string]) { - return StringGetterFunc(func(elem *Element) (s string) { return fmt.Sprintf(f, bind.JawsGet(elem)) }, bind) + return StringGetterFunc(func(elem *jaws.Element) (s string) { return fmt.Sprintf(f, bind.JawsGet(elem)) }, bind) } // FormatHTML returns a HTMLGetter using fmt.Sprintf(f, JawsGet[T](elem)). // Ensure that the generated string is valid HTML. func (bind binding[T]) FormatHTML(f string) (getter HTMLGetter) { - return HTMLGetterFunc(func(elem *Element) (tmpl template.HTML) { + return HTMLGetterFunc(func(elem *jaws.Element) (tmpl template.HTML) { return template.HTML( /*#nosec G203*/ fmt.Sprintf(f, bind.JawsGet(elem))) }, bind) } @@ -118,20 +121,20 @@ func (bind binding[T]) FormatHTML(f string) (getter HTMLGetter) { func wrapSuccessHook(fn any) (hook BindSuccessHook) { switch fn := fn.(type) { case func(): - return func(*Element) error { + return func(*jaws.Element) error { fn() return nil } case func() error: - return func(*Element) error { + return func(*jaws.Element) error { return fn() } - case func(*Element): - return func(elem *Element) error { + case func(*jaws.Element): + return func(elem *jaws.Element) error { fn(elem) return nil } - case func(*Element) error: + case func(*jaws.Element) error: return fn } panic("Binding[T].Success(): function has wrong signature") diff --git a/core/bindinghook.go b/lib/bind/bindinghook.go similarity index 80% rename from core/bindinghook.go rename to lib/bind/bindinghook.go index d78a2c91..4f5ea6cb 100644 --- a/core/bindinghook.go +++ b/lib/bind/bindinghook.go @@ -1,8 +1,10 @@ -package jaws +package bind import ( "fmt" "html/template" + + "github.com/linkdata/jaws" ) type bindingHook[T comparable] struct { @@ -14,27 +16,27 @@ func (bind bindingHook[T]) JawsBinderPrev() Binder[T] { return bind.Binder } -func (bind bindingHook[T]) JawsGetLocked(elem *Element) T { +func (bind bindingHook[T]) JawsGetLocked(elem *jaws.Element) T { if fn, ok := bind.hook.(BindGetHook[T]); ok { return fn(bind.Binder, elem) } return bind.Binder.JawsGetLocked(elem) } -func (bind bindingHook[T]) JawsGet(elem *Element) T { +func (bind bindingHook[T]) JawsGet(elem *jaws.Element) T { bind.RLock() defer bind.RUnlock() return bind.JawsGetLocked(elem) } -func (bind bindingHook[T]) JawsSetLocked(elem *Element, value T) error { +func (bind bindingHook[T]) JawsSetLocked(elem *jaws.Element, value T) error { if fn, ok := bind.hook.(BindSetHook[T]); ok { return fn(bind.Binder, elem, value) } return bind.Binder.JawsSetLocked(elem, value) } -func (bind bindingHook[T]) jawsSetLocking(elem *Element, value T) (err error) { +func (bind bindingHook[T]) jawsSetLocking(elem *jaws.Element, value T) (err error) { bind.Lock() defer bind.Unlock() return bind.JawsSetLocked(elem, value) @@ -48,11 +50,11 @@ const ( callChainClicked ) -func callChain[T comparable](binder Binder[T], elem *Element, kind callChainType, param any) (err error) { +func callChain[T comparable](binder Binder[T], elem *jaws.Element, kind callChainType, param any) (err error) { if prev := binder.JawsBinderPrev(); prev != nil { err = callChain(prev, elem, kind, param) } else if kind == callChainClicked { - err = ErrEventUnhandled + err = jaws.ErrEventUnhandled } if bh, ok := binder.(bindingHook[T]); ok { switch kind { @@ -63,7 +65,7 @@ func callChain[T comparable](binder Binder[T], elem *Element, kind callChainType } } case callChainClicked: - if err == ErrEventUnhandled { + if err == jaws.ErrEventUnhandled { if fn, ok := bh.hook.(BindClickedHook[T]); ok { err = fn(bh, elem, param.(string)) } @@ -73,14 +75,14 @@ func callChain[T comparable](binder Binder[T], elem *Element, kind callChainType return } -func (bind bindingHook[T]) JawsSet(elem *Element, value T) (err error) { +func (bind bindingHook[T]) JawsSet(elem *jaws.Element, value T) (err error) { if err = bind.jawsSetLocking(elem, value); err == nil { err = callChain(bind, elem, callChainSuccess, nil) } return } -func (bind bindingHook[T]) JawsClick(elem *Element, name string) (err error) { +func (bind bindingHook[T]) JawsClick(elem *jaws.Element, name string) (err error) { err = callChain(bind, elem, callChainClicked, name) return } @@ -141,13 +143,13 @@ func (bind bindingHook[T]) Success(fn any) Binder[T] { // Format returns a Getter[string] using fmt.Sprintf(f, JawsGet[T](elem)) func (bind bindingHook[T]) Format(f string) (getter Getter[string]) { - return StringGetterFunc(func(elem *Element) (s string) { return fmt.Sprintf(f, bind.JawsGet(elem)) }, bind) + return StringGetterFunc(func(elem *jaws.Element) (s string) { return fmt.Sprintf(f, bind.JawsGet(elem)) }, bind) } // FormatHTML returns a HTMLGetter using fmt.Sprintf(f, JawsGet[T](elem)). // Ensure that the generated string is valid HTML. func (bind bindingHook[T]) FormatHTML(f string) (getter HTMLGetter) { - return HTMLGetterFunc(func(elem *Element) (tmpl template.HTML) { + return HTMLGetterFunc(func(elem *jaws.Element) (tmpl template.HTML) { return template.HTML( /*#nosec G203*/ fmt.Sprintf(f, bind.JawsGet(elem))) }, bind) } diff --git a/core/getter.go b/lib/bind/getter.go similarity index 66% rename from core/getter.go rename to lib/bind/getter.go index 8d37cb4f..07ddfbb6 100644 --- a/core/getter.go +++ b/lib/bind/getter.go @@ -1,29 +1,32 @@ -package jaws +package bind import ( "errors" "fmt" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) var ErrValueNotSettable = errors.New("value not settable") type Getter[T comparable] interface { - JawsGet(elem *Element) (value T) + JawsGet(elem *jaws.Element) (value T) } type getterStatic[T comparable] struct { v T } -func (getterStatic[T]) JawsSet(*Element, T) error { +func (getterStatic[T]) JawsSet(*jaws.Element, T) error { return ErrValueNotSettable } -func (s getterStatic[T]) JawsGet(*Element) T { +func (s getterStatic[T]) JawsGet(*jaws.Element) T { return s.v } -func (s getterStatic[T]) JawsGetTag(*Request) any { +func (s getterStatic[T]) JawsGetTag(jtag.Context) any { return nil } diff --git a/core/getter_test.go b/lib/bind/getter_test.go similarity index 85% rename from core/getter_test.go rename to lib/bind/getter_test.go index 19375bfd..1beb8c16 100644 --- a/core/getter_test.go +++ b/lib/bind/getter_test.go @@ -1,6 +1,10 @@ -package jaws +package bind -import "testing" +import ( + "testing" + + "github.com/linkdata/jaws/lib/jtag" +) 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.(jtag.TagGetter).JawsGetTag(nil); tag != nil { t.Fatalf("expected nil tag, got %#v", tag) } diff --git a/core/htmlgetter.go b/lib/bind/htmlgetter.go similarity index 63% rename from core/htmlgetter.go rename to lib/bind/htmlgetter.go index 5ea71324..ebd18a7b 100644 --- a/core/htmlgetter.go +++ b/lib/bind/htmlgetter.go @@ -1,10 +1,12 @@ -package jaws +package bind import ( "html/template" + + "github.com/linkdata/jaws" ) // A HTMLGetter is the primary way to deliver generated HTML content to dynamic HTML nodes. type HTMLGetter interface { - JawsGetHTML(e *Element) template.HTML + JawsGetHTML(e *jaws.Element) template.HTML } diff --git a/lib/bind/htmlgetterfunc.go b/lib/bind/htmlgetterfunc.go new file mode 100644 index 00000000..7d44782a --- /dev/null +++ b/lib/bind/htmlgetterfunc.go @@ -0,0 +1,28 @@ +package bind + +import ( + "html/template" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" +) + +type htmlGetterFunc struct { + fn func(*jaws.Element) template.HTML + tags []any +} + +var _ jtag.TagGetter = &htmlGetterFunc{} + +func (g *htmlGetterFunc) JawsGetHTML(e *jaws.Element) template.HTML { + return g.fn(e) +} + +func (g *htmlGetterFunc) JawsGetTag(jtag.Context) any { + return g.tags +} + +// HTMLGetterFunc wraps a function and returns a HTMLGetter. +func HTMLGetterFunc(fn func(elem *jaws.Element) (tmpl template.HTML), tags ...any) HTMLGetter { + return &htmlGetterFunc{fn: fn, tags: tags} +} diff --git a/lib/bind/htmlgetterfunc_test.go b/lib/bind/htmlgetterfunc_test.go new file mode 100644 index 00000000..e9006568 --- /dev/null +++ b/lib/bind/htmlgetterfunc_test.go @@ -0,0 +1,23 @@ +package bind + +import ( + "html/template" + "reflect" + "testing" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" +) + +func TestHTMLGetterFunc(t *testing.T) { + tt := &selfTagger{} + hg := HTMLGetterFunc(func(e *jaws.Element) template.HTML { + return "foo" + }, tt) + if s := hg.JawsGetHTML(nil); s != "foo" { + t.Error(s) + } + if got := jtag.MustTagExpand(nil, hg); !reflect.DeepEqual(got, []any{tt}) { + t.Error(got) + } +} diff --git a/core/makehtmlgetter.go b/lib/bind/makehtmlgetter.go similarity index 79% rename from core/makehtmlgetter.go rename to lib/bind/makehtmlgetter.go index 2bec0128..30e51340 100644 --- a/core/makehtmlgetter.go +++ b/lib/bind/makehtmlgetter.go @@ -1,44 +1,47 @@ -package jaws +package bind import ( "fmt" "html" "html/template" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) type htmlGetter struct{ v template.HTML } -func (g htmlGetter) JawsGetHTML(e *Element) template.HTML { +func (g htmlGetter) JawsGetHTML(e *jaws.Element) template.HTML { return g.v } -func (g htmlGetter) JawsGetTag(rq *Request) any { +func (g htmlGetter) JawsGetTag(jtag.Context) any { return nil } type htmlStringerGetter struct{ sg fmt.Stringer } -func (g htmlStringerGetter) JawsGetHTML(e *Element) template.HTML { +func (g htmlStringerGetter) JawsGetHTML(e *jaws.Element) template.HTML { return template.HTML(html.EscapeString(g.sg.String())) // #nosec G203 } -func (g htmlStringerGetter) JawsGetTag(rq *Request) any { +func (g htmlStringerGetter) JawsGetTag(jtag.Context) any { return g.sg } type htmlBinderString struct{ Binder[string] } -func (g htmlBinderString) JawsGetHTML(e *Element) template.HTML { +func (g htmlBinderString) JawsGetHTML(e *jaws.Element) template.HTML { return template.HTML(html.EscapeString(g.Binder.JawsGet(e))) // #nosec G203 } type htmlGetterString struct{ sg Getter[string] } -func (g htmlGetterString) JawsGetHTML(e *Element) template.HTML { +func (g htmlGetterString) JawsGetHTML(e *jaws.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(jtag.Context) any { return g.sg } diff --git a/core/makehtmlgetter_test.go b/lib/bind/makehtmlgetter_test.go similarity index 94% rename from core/makehtmlgetter_test.go rename to lib/bind/makehtmlgetter_test.go index 42bea59e..0d55278c 100644 --- a/core/makehtmlgetter_test.go +++ b/lib/bind/makehtmlgetter_test.go @@ -1,4 +1,4 @@ -package jaws +package bind import ( "html" @@ -6,6 +6,8 @@ import ( "reflect" "sync/atomic" "testing" + + "github.com/linkdata/jaws/lib/jtag" ) 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.(jtag.TagGetter).JawsGetTag(nil); tag != tt.tag { t.Errorf("MakeHTMLGetter(%s).JawsGetTag() = %v, want %v", tt.name, tag, tt.tag) } }) diff --git a/core/rwlocker.go b/lib/bind/rwlocker.go similarity index 93% rename from core/rwlocker.go rename to lib/bind/rwlocker.go index be68809e..bdcfee71 100644 --- a/core/rwlocker.go +++ b/lib/bind/rwlocker.go @@ -1,4 +1,4 @@ -package jaws +package bind import "sync" diff --git a/core/setter.go b/lib/bind/setter.go similarity index 61% rename from core/setter.go rename to lib/bind/setter.go index 00f2e417..81e48e3c 100644 --- a/core/setter.go +++ b/lib/bind/setter.go @@ -1,24 +1,27 @@ -package jaws +package bind import ( "fmt" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) type Setter[T comparable] interface { Getter[T] // JawsSet may return ErrValueUnchanged to indicate value was already set. - JawsSet(elem *Element, value T) (err error) + JawsSet(elem *jaws.Element, value T) (err error) } type setterReadOnly[T comparable] struct { Getter[T] } -func (setterReadOnly[T]) JawsSet(*Element, T) error { +func (setterReadOnly[T]) JawsSet(*jaws.Element, T) error { return ErrValueNotSettable } -func (s setterReadOnly[T]) JawsGetTag(*Request) any { +func (s setterReadOnly[T]) JawsGetTag(jtag.Context) any { return s.Getter } @@ -26,15 +29,15 @@ type setterStatic[T comparable] struct { v T } -func (setterStatic[T]) JawsSet(*Element, T) error { +func (setterStatic[T]) JawsSet(*jaws.Element, T) error { return ErrValueNotSettable } -func (s setterStatic[T]) JawsGet(*Element) T { +func (s setterStatic[T]) JawsGet(*jaws.Element) T { return s.v } -func (s setterStatic[T]) JawsGetTag(*Request) any { +func (s setterStatic[T]) JawsGetTag(jtag.Context) any { return nil } diff --git a/core/setter_test.go b/lib/bind/setter_test.go similarity index 82% rename from core/setter_test.go rename to lib/bind/setter_test.go index 0b954aa6..e68993f4 100644 --- a/core/setter_test.go +++ b/lib/bind/setter_test.go @@ -1,14 +1,17 @@ -package jaws +package bind import ( "testing" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) const testStringGetterText = "" type testGetterString struct{} -func (testGetterString) JawsGet(*Element) string { +func (testGetterString) JawsGet(*jaws.Element) string { return testStringGetterText } @@ -21,7 +24,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.(jtag.TagGetter).JawsGetTag(nil); tag != tsg { t.Error(tag) } @@ -32,7 +35,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.(jtag.TagGetter).JawsGetTag(nil); tag != nil { t.Error(tag) } } diff --git a/core/setterfloat64.go b/lib/bind/setterfloat64.go similarity index 73% rename from core/setterfloat64.go rename to lib/bind/setterfloat64.go index de4c0254..04fa2e17 100644 --- a/core/setterfloat64.go +++ b/lib/bind/setterfloat64.go @@ -1,7 +1,10 @@ -package jaws +package bind import ( "fmt" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) type numeric interface { @@ -13,23 +16,23 @@ type numeric interface { type SetterFloat64[T numeric] interface { Getter[T] // JawsSet may return ErrValueUnchanged to indicate value was already set. - JawsSet(elem *Element, value T) (err error) + JawsSet(elem *jaws.Element, value T) (err error) } type setterFloat64[T numeric] struct { Setter[T] } -func (s setterFloat64[T]) JawsGet(e *Element) float64 { +func (s setterFloat64[T]) JawsGet(e *jaws.Element) float64 { v := s.Setter.JawsGet(e) return float64(v) } -func (s setterFloat64[T]) JawsSet(e *Element, v float64) error { +func (s setterFloat64[T]) JawsSet(e *jaws.Element, v float64) error { return s.Setter.JawsSet(e, T(v)) } -func (s setterFloat64[T]) JawsGetTag(*Request) any { +func (s setterFloat64[T]) JawsGetTag(jtag.Context) any { return s.Setter } @@ -37,16 +40,16 @@ type setterFloat64ReadOnly[T numeric] struct { Getter[T] } -func (s setterFloat64ReadOnly[T]) JawsGet(e *Element) float64 { +func (s setterFloat64ReadOnly[T]) JawsGet(e *jaws.Element) float64 { v := s.Getter.JawsGet(e) return float64(v) } -func (setterFloat64ReadOnly[T]) JawsSet(*Element, float64) error { +func (setterFloat64ReadOnly[T]) JawsSet(*jaws.Element, float64) error { return ErrValueNotSettable } -func (s setterFloat64ReadOnly[T]) JawsGetTag(*Request) any { +func (s setterFloat64ReadOnly[T]) JawsGetTag(jtag.Context) any { return s.Getter } @@ -54,15 +57,15 @@ type setterFloat64Static[T numeric] struct { v float64 } -func (setterFloat64Static[T]) JawsSet(*Element, float64) error { +func (setterFloat64Static[T]) JawsSet(*jaws.Element, float64) error { return ErrValueNotSettable } -func (s setterFloat64Static[T]) JawsGet(*Element) float64 { +func (s setterFloat64Static[T]) JawsGet(*jaws.Element) float64 { return s.v } -func (s setterFloat64Static[T]) JawsGetTag(*Request) any { +func (s setterFloat64Static[T]) JawsGetTag(jtag.Context) any { return nil } diff --git a/core/setterfloat64_test.go b/lib/bind/setterfloat64_test.go similarity index 90% rename from core/setterfloat64_test.go rename to lib/bind/setterfloat64_test.go index 4752998a..3c095a9d 100644 --- a/core/setterfloat64_test.go +++ b/lib/bind/setterfloat64_test.go @@ -1,15 +1,18 @@ -package jaws +package bind import ( "reflect" "testing" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) type testGetter[T comparable] struct { v T } -func (tg testGetter[T]) JawsGet(*Element) T { +func (tg testGetter[T]) JawsGet(*jaws.Element) T { return tg.v } @@ -70,7 +73,7 @@ func Test_makeSetterFloat64_int(t *testing.T) { if x := gotS.JawsGet(nil); x != 1 { t.Error(x) } - tg := gotS.(TagGetter) + tg := gotS.(jtag.TagGetter) if x := tg.JawsGetTag(nil); x != tsint { t.Error(x) } @@ -86,7 +89,7 @@ func Test_makeSetterFloat64ReadOnly_int(t *testing.T) { if x := gotS.JawsGet(nil); x != 1 { t.Error(x) } - tg := gotS.(TagGetter) + tg := gotS.(jtag.TagGetter) if x := tg.JawsGetTag(nil); x != tgint { t.Error(x) } @@ -102,7 +105,7 @@ func Test_makeSetterFloat64Static_int(t *testing.T) { if x := gotS.JawsGet(nil); x != 1 { t.Error(x) } - tg := gotS.(TagGetter) + tg := gotS.(jtag.TagGetter) if x := tg.JawsGetTag(nil); x != nil { t.Error(x) } diff --git a/lib/bind/stringgetterfunc.go b/lib/bind/stringgetterfunc.go new file mode 100644 index 00000000..2d70ad64 --- /dev/null +++ b/lib/bind/stringgetterfunc.go @@ -0,0 +1,24 @@ +package bind + +import ( + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" +) + +type stringGetterFunc struct { + fn func(*jaws.Element) string + tags []any +} + +func (g *stringGetterFunc) JawsGet(e *jaws.Element) string { + return g.fn(e) +} + +func (g *stringGetterFunc) JawsGetTag(jtag.Context) any { + return g.tags +} + +// StringGetterFunc wraps a function and returns a Getter[string] +func StringGetterFunc(fn func(elem *jaws.Element) (s string), tags ...any) Getter[string] { + return &stringGetterFunc{fn: fn, tags: tags} +} diff --git a/lib/bind/stringgetterfunc_test.go b/lib/bind/stringgetterfunc_test.go new file mode 100644 index 00000000..2596af09 --- /dev/null +++ b/lib/bind/stringgetterfunc_test.go @@ -0,0 +1,22 @@ +package bind + +import ( + "reflect" + "testing" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" +) + +func TestStringGetterFunc(t *testing.T) { + tt := &selfTagger{} + sg := StringGetterFunc(func(e *jaws.Element) string { + return "foo" + }, tt) + if s := sg.JawsGet(nil); s != "foo" { + t.Error(s) + } + if got := jtag.MustTagExpand(nil, sg); !reflect.DeepEqual(got, []any{tt}) { + t.Error(got) + } +} diff --git a/core/testsetter_test.go b/lib/bind/testsetter_test.go similarity index 62% rename from core/testsetter_test.go rename to lib/bind/testsetter_test.go index f598af9c..dab6608f 100644 --- a/core/testsetter_test.go +++ b/lib/bind/testsetter_test.go @@ -1,7 +1,9 @@ -package jaws +package bind import ( "github.com/linkdata/deadlock" + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/jtag" ) type testSetter[T comparable] struct { @@ -9,8 +11,8 @@ type testSetter[T comparable] struct { val T err error setCount int - setCalled chan struct{} getCount int + setCalled chan struct{} getCalled chan struct{} } @@ -22,16 +24,28 @@ func newTestSetter[T comparable](val T) *testSetter[T] { } } -func (ts *testSetter[T]) Get() (s T) { +func (ts *testSetter[T]) Get() (val T) { ts.mu.Lock() - s = ts.val + val = ts.val ts.mu.Unlock() return } -func (ts *testSetter[T]) Set(s T) { +func (ts *testSetter[T]) Set(val T) { + ts.mu.Lock() + ts.val = val + ts.mu.Unlock() +} + +func (ts *testSetter[T]) Err() error { ts.mu.Lock() - ts.val = s + defer ts.mu.Unlock() + return ts.err +} + +func (ts *testSetter[T]) SetErr(err error) { + ts.mu.Lock() + ts.err = err ts.mu.Unlock() } @@ -49,7 +63,7 @@ func (ts *testSetter[T]) GetCount() (n int) { return } -func (ts *testSetter[T]) JawsGet(e *Element) (val T) { +func (ts *testSetter[T]) JawsGet(*jaws.Element) (val T) { ts.mu.Lock() defer ts.mu.Unlock() ts.getCount++ @@ -60,7 +74,7 @@ func (ts *testSetter[T]) JawsGet(e *Element) (val T) { return } -func (ts *testSetter[T]) JawsSet(e *Element, val T) (err error) { +func (ts *testSetter[T]) JawsSet(_ *jaws.Element, val T) (err error) { ts.mu.Lock() defer ts.mu.Unlock() ts.setCount++ @@ -69,14 +83,14 @@ func (ts *testSetter[T]) JawsSet(e *Element, val T) (err error) { } if err = ts.err; err == nil { if ts.val == val { - err = ErrValueUnchanged + err = jaws.ErrValueUnchanged } ts.val = val } return } -func (ts *testSetter[string]) JawsGetString(e *Element) (val string) { +func (ts *testSetter[string]) JawsGetString(*jaws.Element) (val string) { ts.mu.Lock() defer ts.mu.Unlock() ts.getCount++ @@ -87,7 +101,7 @@ func (ts *testSetter[string]) JawsGetString(e *Element) (val string) { return } -func (ts *testSetter[any]) JawsGetAny(e *Element) (val any) { +func (ts *testSetter[any]) JawsGetAny(*jaws.Element) (val any) { ts.mu.Lock() defer ts.mu.Unlock() ts.getCount++ @@ -98,7 +112,7 @@ func (ts *testSetter[any]) JawsGetAny(e *Element) (val any) { return } -func (ts *testSetter[any]) JawsSetAny(e *Element, val any) (err error) { +func (ts *testSetter[any]) JawsSetAny(_ *jaws.Element, val any) (err error) { ts.mu.Lock() defer ts.mu.Unlock() ts.setCount++ @@ -107,14 +121,14 @@ func (ts *testSetter[any]) JawsSetAny(e *Element, val any) (err error) { } if err = ts.err; err == nil { if ts.val == val { - err = ErrValueUnchanged + err = jaws.ErrValueUnchanged } ts.val = val } return } -func (ts *testSetter[T]) JawsGetHTML(e *Element) (val T) { +func (ts *testSetter[T]) JawsGetHTML(*jaws.Element) (val T) { ts.mu.Lock() defer ts.mu.Unlock() ts.getCount++ @@ -124,3 +138,9 @@ func (ts *testSetter[T]) JawsGetHTML(e *Element) (val T) { val = ts.val return } + +type selfTagger struct{} + +func (st *selfTagger) JawsGetTag(jtag.Context) any { + return st +} diff --git a/core/writehtml.go b/lib/htmlio/writehtml.go similarity index 72% rename from core/writehtml.go rename to lib/htmlio/writehtml.go index f1202a60..8b1e458c 100644 --- a/core/writehtml.go +++ b/lib/htmlio/writehtml.go @@ -1,11 +1,11 @@ -package jaws +package htmlio import ( "html/template" "io" "strconv" - "github.com/linkdata/jaws/jid" + "github.com/linkdata/jaws/lib/jid" ) var singletonTags = map[string]struct{}{ @@ -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