🌶️ pepper
Create reactive frontends without ever writing frontend code.
- How Does It Work?
- What Should/Shouldn't I Use It For?
- Handling Offline and Reconnecting
- Testing
- Examples
How Does It Work?
pepper runs a HTTP server that returns a tiny empty HTML page with just a few lines of inline javascript. Immediately after the initial page loads it will connect through a websocket.
All event triggered on the browser will be sent through the websocket where state changes and rerendering occurs. The result is passed back to the websocket to update the UI.
At the moment it returns the whole rendered component. However, this could be optimized in the future to only return the differences.
What Should/Shouldn't I Use It For?
pepper requires a constant connection to the server (for the websocket) so it wouldn't work for anything that must function offline, or used in cases where the internet is flaky.
I imagine some good use cases for pepper would be:
- Showing real time data. Streaming metrics, graphs, logs, dashboards, etc.
- Apps that rely on a persistent connection. Such as chat clients, timed interactive exams, etc.
- Apps that would benefit from persistent state. The entire state can be saved or restored into a serialized format like JSON. Great for forms or surveys with many questions/steps.
- Prototyping a frontend app. It's super easy to get up and running and iterate changes without setting up a complex environment, build tools and dependencies.
Handling Offline and Reconnecting
When creating the server you may configure how you want disconnects to be handled. For example:
server := pepper.NewServer()
server.OfflineAction = pepper.OfflineActionDisableForms
By default clients will try to reconnect every second. This can be changed with
server.ReconnectInterval
.
Important: When reconnecting the server treats the new request as a new client, so all state on the page will be lost.
Testing
Unit Testing
The peppertest
package provides tools to make unit testing easier.
RenderToDocument
renders then parses the component into a *Document
from the
github.com/PuerkitoBio/goquery
package:
import (
"github.com/elliotchance/pepper/peppertest"
"github.com/stretchr/testify/require"
"testing"
)
func TestPeople_Add(t *testing.T) {
c := &People{
Names: []string{"Jack", "Jill"},
}
c.Name = "Bob"
c.Add()
doc, err := peppertest.RenderToDocument(c)
require.NoError(t, err)
rows := doc.Find("tr")
require.Equal(t, 3, rows.Length())
require.Contains(t, rows.Eq(0).Text(), "Jack")
require.Contains(t, rows.Eq(1).Text(), "Jill")
require.Contains(t, rows.Eq(2).Text(), "Bob")
}
Each of the examples in the examples/ directory include unit tests.
Examples
#1: A Simple Counter
package main
import "github.com/elliotchance/pepper"
type Counter struct {
Number int
}
func (c *Counter) Render() (string, error) {
return `
Counter: {{ .Number }}
<button @click="AddOne">+</button>
`, nil
}
func (c *Counter) AddOne() {
c.Number++
}
func main() {
panic(pepper.NewServer().Start(func(_ *pepper.Connection) pepper.Component {
return &Counter{}
}))
}
- The
Render
method returns ahtml/template
syntax, or an error. @click
will triggerAddOne
to be called when the button is clicked.- Any event triggered from the browser will cause the component to rerender automatically.
Try it now:
go get -u github.com/elliotchance/pepper/examples/ex01_counter
ex01_counter
Then open: http://localhost:8080/
#2: Forms
package main
import (
"github.com/elliotchance/pepper"
"strconv"
)
type People struct {
Names []string
Name string
}
func (c *People) Render() (string, error) {
return `
<table>
{{ range $i, $name := .Names }}
<tr><td>
{{ $name }}
<button key="{{ $i }}" @click="Delete">Delete</button>
</td></tr>
{{ end }}
</table>
Add name: <input type="text" @value="Name">
<button @click="Add">Add</button>
`, nil
}
func (c *People) Delete(key string) {
index, _ := strconv.Atoi(key)
c.Names = append(c.Names[:index], c.Names[index+1:]...)
}
func (c *People) Add() {
c.Names = append(c.Names, c.Name)
c.Name = ""
}
func main() {
panic(pepper.NewServer().Start(func(_ *pepper.Connection) pepper.Component {
return &People{
Names: []string{"Jack", "Jill"},
}
}))
}
- Any
html/template
syntax will work, including loops with{{ range }}
. @value
will cause theName
property to be bound with the text box in both directions.- Since there are multiple "Delete" buttons (one for each person), you should
specify a
key
. Thekey
is passed as the first argument to theDelete
function.
Try it now:
go get -u github.com/elliotchance/pepper/examples/ex02_form
ex02_form
Then open: http://localhost:8080/
#3: Nested Components
type Counters struct {
Counters []*Counter
}
func (c *Counters) Render() (string, error) {
return `
<table>
{{ range .Counters }}
<tr><td>
{{ render . }}
</td></tr>
{{ end }}
<tr><td>
Total: {{ call .Total }}
</td></tr>
</table>
`, nil
}
func (c *Counters) Total() int {
total := 0
for _, counter := range c.Counters {
total += counter.Number
}
return total
}
func main() {
panic(pepper.NewServer().Start(func(_ *pepper.Connection) pepper.Component {
return &Counters{
Counters: []*Counter{
{}, {}, {},
},
}
}))
}
- This example uses three
Counter
components (from Example #1) and includes a live total. - Components can be nested with the
render
function. The nested components do not need to be modified in any way. - Invoke methods with the
call
function.
Try it now:
go get -u github.com/elliotchance/pepper/examples/ex03_nested
ex03_nested
Then open: http://localhost:8080/
#4: Ticker
package main
import (
"github.com/elliotchance/pepper"
"time"
)
type Clock struct{}
func (c *Clock) Render() (string, error) {
return `
The time now is {{ call .Now }}.
`, nil
}
func (c *Clock) Now() string {
return time.Now().Format(time.RFC1123)
}
func main() {
panic(pepper.NewServer().Start(func(conn *pepper.Connection) pepper.Component {
go func() {
for range time.NewTicker(time.Second).C {
conn.Update()
}
}()
return &Clock{}
}))
}
- The component is updated once per second so the client sees the active time.
Try it now:
go get -u github.com/elliotchance/pepper/examples/ex04_ticker
ex04_ticker
Then open: http://localhost:8080/