Skip to content

Commit

Permalink
feat: added HTTP redirect support; added cache-control: no-store header
Browse files Browse the repository at this point in the history
Signed-off-by: Ben Meier <[email protected]>
  • Loading branch information
astromechza committed Oct 27, 2024
1 parent 1c90e55 commit 17efea0
Show file tree
Hide file tree
Showing 6 changed files with 55 additions and 8 deletions.
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This library is a utility library for synchronising [automerge](https://automerg
1. The response body is closed after the server detects that the request body is complete and no more messages are available.
2. The client sees a sync message that meets its "termination check", which may indicate that the server matches the local state or that the local state contains all the remote head nodes. This can be used for local tools that need to perform a "one-shot" synchronisation on startup.
5. There's a broadcast capability that allows a server to serve changes from multiple clients on the same doc simultaneously or for a client to synchronise with multiple servers.
6. The client supports HTTP redirect behavior so that servers can implement rudimentary partitioning and balancing of requests.

This library will be used to build a series of small peer-to-peer and distributed state utilities built on Automerge. The protocol above is easy to replicate in most languages, most importantly Go (in this repo) and Javascript.

Expand Down Expand Up @@ -50,10 +51,10 @@ Until I decide to hang up the connection with Ctrl-C. And this works perfectly f

```
$ curl -k -i -X PUT https://localhost:8080/example -d '{"event":"sync","data":"QgAAAQAAAA=="}' -H 'Content-Type: application/x-ndjson' --http1.1
HTTP/1.1 200 OK
Content-Type: application/x-ndjson; charset=utf-8
Date: Sat, 26 Oct 2024 11:32:52 GMT
Transfer-Encoding: chunked
< HTTP/1.1 200 OK
< Content-Type: application/x-ndjson; charset=utf-8
< Date: Sat, 26 Oct 2024 11:32:52 GMT
< Transfer-Encoding: chunked
{"event":"sync","data":"QgFbkqa2LT<snip>9CqZQD6wA="}
{"event":"sync","data":"QgEVJTDZHR<snip>F/AH+BAQ=="}
Expand Down
4 changes: 4 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func (b *SharedDoc) HttpPushPullChanges(ctx context.Context, url string, opts ..
}
r.Header.Set("Content-Type", ContentTypeWithCharset)
r.Header.Set("Accept", ContentType)
r.Header.Set("Cache-Control", "no-store")
// We don't need to send the body content if the server will reject it, so we can notify that expect-continue is supported.
r.Header.Set("Expect", "100-continue")
for _, editor := range o.reqEditors {
Expand All @@ -145,6 +146,9 @@ func (b *SharedDoc) HttpPushPullChanges(ctx context.Context, url string, opts ..

// We use a special body generator that runs in a goroutine on demand in order to generate new messages.
r.Body = newMessageGenerator(ctx, o.state, sub, wg)
r.GetBody = func() (io.ReadCloser, error) {
return newMessageGenerator(ctx, o.state, sub, wg), nil
}

res, err := o.client.Do(r)
if err != nil {
Expand Down
9 changes: 5 additions & 4 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,11 @@ func TestHttpPushPullChanges(t *testing.T) {
assertEqual(t, sd.HttpPushPullChanges(context.Background(), "https://localhost", WithHttpClient(HttpDoerFunc(func(request *http.Request) (*http.Response, error) {
assertEqual(t, request.URL.String(), "https://localhost")
assertEqual(t, request.Header, map[string][]string{
"Accept": {ContentType},
"Content-Type": {ContentTypeWithCharset},
"Expect": {"100-continue"},
"Test-Header": {"Test-Value"},
"Accept": {ContentType},
"Content-Type": {ContentTypeWithCharset},
"Cache-Control": {"no-store"},
"Expect": {"100-continue"},
"Test-Header": {"Test-Value"},
})

sc := bufio.NewScanner(request.Body)
Expand Down
38 changes: 38 additions & 0 deletions examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"sync"
"testing"

Expand Down Expand Up @@ -76,3 +78,39 @@ func Test_sync3(t *testing.T) {
assertEqual(t, values["peer-0"].Int64(), 0)
assertEqual(t, values["peer-1"].Int64(), 1)
}

// It's very useful to be able to load balance clients via HTTP redirect semantics.
func TestExample_HttpRedirect(t *testing.T) {
t.Parallel()

SetLog(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))

// Create a starting server side doc that has some existing content.
sd := NewSharedDoc(automerge.New())
assertEqual(t, sd.Doc().RootMap().Set("a", "b"), nil)
_, _ = sd.Doc().Commit("change")

mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/redirected")
w.WriteHeader(http.StatusTemporaryRedirect)
})
mux.HandleFunc("/redirected", func(w http.ResponseWriter, r *http.Request) {
if err := sd.ServeChanges(w, r); err != nil {
t.Fatal(err)
}
})

listener, err := net.Listen("tcp", "127.0.0.1:0")
assertEqual(t, err, nil)
server := &http.Server{Handler: mux}
defer server.Close()
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()

peerDoc := NewSharedDoc(automerge.New())
assertEqual(t, peerDoc.HttpPushPullChanges(context.Background(), "http://"+listener.Addr().String(), WithClientTerminationCheck(HasAllRemoteHeads)), nil)
}
1 change: 1 addition & 0 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func (b *SharedDoc) ServeChanges(rw http.ResponseWriter, req *http.Request, opts
log.InfoContext(ctx, "sending http sync response", slog.String("proto", req.Proto), slog.String("target", fmt.Sprintf("%s %s", req.Method, req.URL)), slog.Int("status", http.StatusOK))
rw.Header().Set("Content-Type", ContentTypeWithCharset)
rw.Header().Set("X-Content-Type-Options", "nosniff")
rw.Header().Set("Cache-Control", "no-store")
for _, he := range options.headerEditors {
he(rw.Header())
}
Expand Down
2 changes: 2 additions & 0 deletions server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestServe_empty_request_body(t *testing.T) {
assertEqual(t, rw.Result().Header, map[string][]string{
"Content-Type": {ContentTypeWithCharset},
"X-Content-Type-Options": {"nosniff"},
"Cache-Control": {"no-store"},
"Test-Header": {"Test-Value"},
})
assertEqual(t, rw.Body.String(), "{\"event\":\"sync\",\"data\":\"QgAAAQAAAA==\"}\n")
Expand Down Expand Up @@ -78,6 +79,7 @@ func TestServe_exchange(t *testing.T) {
assertEqual(t, rw.Result().Header, map[string][]string{
"Content-Type": {ContentTypeWithCharset},
"X-Content-Type-Options": {"nosniff"},
"Cache-Control": {"no-store"},
})
lines := strings.Split(strings.TrimSpace(rw.Body.String()), "\n")
assertEqual(t, len(lines), 2)
Expand Down

0 comments on commit 17efea0

Please sign in to comment.