vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Highlight with Prism in a worker and gzip text responses
306f36fcef89c145abcd0c5331453d0b179f37ce · Keenan Nemetz · 12d ago · parent e5542ac6
Highlight with Prism in a worker and gzip text responses
The code browser now names a blob's language and lets the browser colour it.
Chroma is gone: server/web/highlight.go is an extension table, and
static/prism-worker.js runs Prism — every language Prism ships — off the main
thread. The server sends escaped lines with their numbers and L# anchors, so a
page is complete and scrollable before any script runs and the worker only swaps
the contents of each line cell. The table covers 186 languages; a file it does
not claim stays plain rather than being coloured by a guess.
The bundle those grammars make is 545 KB, so the instance now gzips text
responses itself instead of relying on a proxy, and static assets carry a
content ETag so a browser revalidates with a 304 rather than re-downloading the
bundle on every code page. The raw-download route opts out with
Cache-Control: no-transform because it promises an exact Content-Length.
This also makes JavaScript a requirement rather than a nicety: the label and
assignee token inputs lose their noscript save buttons and their comma-separated
text fallback, and the docs say so.
Measured: release binary 26.08 MB -> 22.70 MB; /r/{repo}/code 11.1 KB -> 2.6 KB
over the wire; the Prism bundle 545 KB -> 206 KB gzipped, fetched once.
scripts/vendor-prism.py regenerates the bundle from Prism's components.json
require graph, and --check fails if the committed assets drift.
server/compress_test.go +155
| @@ -0,0 +1,155 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "compress/gzip" | ||
| 5 | "io" | ||
| 6 | "net/http" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // gzipGet fetches a URL as a browser that accepts gzip would. The header is set | ||
| 12 | // by hand so Go's transport does not transparently decode the body: the test | ||
| 13 | // wants to see what went over the wire. | ||
| 14 | func (f *fixture) gzipGet(t *testing.T, path string, headers map[string]string) *http.Response { | ||
| 15 | t.Helper() | ||
| 16 | req, err := http.NewRequest(http.MethodGet, f.http.URL+path, nil) | ||
| 17 | if err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | req.Header.Set("Accept-Encoding", "gzip") | ||
| 21 | for k, v := range headers { | ||
| 22 | req.Header.Set(k, v) | ||
| 23 | } | ||
| 24 | resp, err := (&http.Client{}).Do(req) | ||
| 25 | if err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | t.Cleanup(func() { resp.Body.Close() }) | ||
| 29 | return resp | ||
| 30 | } | ||
| 31 | |||
| 32 | func gunzip(t *testing.T, body io.Reader) string { | ||
| 33 | t.Helper() | ||
| 34 | zr, err := gzip.NewReader(body) | ||
| 35 | if err != nil { | ||
| 36 | t.Fatalf("body is not gzip: %v", err) | ||
| 37 | } | ||
| 38 | raw, err := io.ReadAll(zr) | ||
| 39 | if err != nil { | ||
| 40 | t.Fatalf("read gzip body: %v", err) | ||
| 41 | } | ||
| 42 | return string(raw) | ||
| 43 | } | ||
| 44 | |||
| 45 | // TestPagesAreCompressedWhenTheClientAsks: a rendered page is mostly markup, and | ||
| 46 | // the code browser ships a blob table, so the whole surface is worth gzipping. | ||
| 47 | func TestPagesAreCompressedWhenTheClientAsks(t *testing.T) { | ||
| 48 | t.Parallel() | ||
| 49 | f := newFixture(t) | ||
| 50 | f.publish(f.repo) | ||
| 51 | |||
| 52 | resp := f.gzipGet(t, "/r/"+f.repo+"/code", nil) | ||
| 53 | if resp.StatusCode != http.StatusOK { | ||
| 54 | t.Fatalf("code page = %d", resp.StatusCode) | ||
| 55 | } | ||
| 56 | if got := resp.Header.Get("Content-Encoding"); got != "gzip" { | ||
| 57 | t.Fatalf("Content-Encoding = %q, want gzip", got) | ||
| 58 | } | ||
| 59 | if !strings.Contains(strings.Join(resp.Header.Values("Vary"), ","), "Accept-Encoding") { | ||
| 60 | t.Fatalf("Vary does not name Accept-Encoding: %v", resp.Header.Values("Vary")) | ||
| 61 | } | ||
| 62 | if got := resp.Header.Get("Content-Length"); got != "" { | ||
| 63 | t.Fatalf("compressed page announced Content-Length %q", got) | ||
| 64 | } | ||
| 65 | if page := gunzip(t, resp.Body); !strings.Contains(page, `id="file-tree"`) { | ||
| 66 | t.Fatalf("decompressed page is not the code browser:\n%s", page[:min(len(page), 300)]) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | // TestCompressionIsOptional: a client that does not ask for gzip (curl without | ||
| 71 | // --compressed, a Git client, a script reading a header) gets plain bytes. | ||
| 72 | func TestCompressionIsOptional(t *testing.T) { | ||
| 73 | t.Parallel() | ||
| 74 | f := newFixture(t) | ||
| 75 | f.publish(f.repo) | ||
| 76 | |||
| 77 | resp := f.gzipGet(t, "/r/"+f.repo+"/code", map[string]string{"Accept-Encoding": "identity"}) | ||
| 78 | if resp.StatusCode != http.StatusOK { | ||
| 79 | t.Fatalf("code page = %d", resp.StatusCode) | ||
| 80 | } | ||
| 81 | if got := resp.Header.Get("Content-Encoding"); got != "" { | ||
| 82 | t.Fatalf("Content-Encoding = %q on an identity response", got) | ||
| 83 | } | ||
| 84 | page, err := io.ReadAll(resp.Body) | ||
| 85 | if err != nil { | ||
| 86 | t.Fatal(err) | ||
| 87 | } | ||
| 88 | if !strings.Contains(string(page), `id="file-tree"`) { | ||
| 89 | t.Fatal("identity page is not the code browser") | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | // TestStaticAssetsRevalidate: the Prism bundles are half a megabyte, so a | ||
| 94 | // browser must be able to ask "still current?" and be told 304 rather than | ||
| 95 | // download them again on every code page. | ||
| 96 | func TestStaticAssetsRevalidate(t *testing.T) { | ||
| 97 | t.Parallel() | ||
| 98 | f := newFixture(t) | ||
| 99 | |||
| 100 | resp := f.gzipGet(t, "/static/vole.css", map[string]string{"Accept-Encoding": "identity"}) | ||
| 101 | if resp.StatusCode != http.StatusOK { | ||
| 102 | t.Fatalf("stylesheet = %d", resp.StatusCode) | ||
| 103 | } | ||
| 104 | etag := resp.Header.Get("ETag") | ||
| 105 | if etag == "" { | ||
| 106 | t.Fatal("stylesheet has no ETag") | ||
| 107 | } | ||
| 108 | if got := resp.Header.Get("Cache-Control"); got != "no-cache" { | ||
| 109 | t.Fatalf("Cache-Control = %q", got) | ||
| 110 | } | ||
| 111 | first, err := io.ReadAll(resp.Body) | ||
| 112 | if err != nil { | ||
| 113 | t.Fatal(err) | ||
| 114 | } | ||
| 115 | if len(first) == 0 { | ||
| 116 | t.Fatal("stylesheet is empty") | ||
| 117 | } | ||
| 118 | |||
| 119 | notModified := f.gzipGet(t, "/static/vole.css", map[string]string{ | ||
| 120 | "Accept-Encoding": "identity", | ||
| 121 | "If-None-Match": etag, | ||
| 122 | }) | ||
| 123 | if notModified.StatusCode != http.StatusNotModified { | ||
| 124 | t.Fatalf("revalidation = %d, want 304", notModified.StatusCode) | ||
| 125 | } | ||
| 126 | if body, _ := io.ReadAll(notModified.Body); len(body) != 0 { | ||
| 127 | t.Fatalf("304 carried %d bytes", len(body)) | ||
| 128 | } | ||
| 129 | |||
| 130 | // The compressed representation keeps the same validator, so a cache keys the | ||
| 131 | // two variants by Vary rather than by ETag. | ||
| 132 | compressed := f.gzipGet(t, "/static/vole.css", nil) | ||
| 133 | if compressed.Header.Get("ETag") != etag { | ||
| 134 | t.Fatalf("compressed ETag = %q, want %q", compressed.Header.Get("ETag"), etag) | ||
| 135 | } | ||
| 136 | if got := compressed.Header.Get("Content-Encoding"); got != "gzip" { | ||
| 137 | t.Fatalf("compressed stylesheet Content-Encoding = %q", got) | ||
| 138 | } | ||
| 139 | if body := gunzip(t, compressed.Body); body != string(first) { | ||
| 140 | t.Fatal("compressed stylesheet decompresses to something else") | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | // TestStaticServesOnlyTheEmbeddedSet: there is no directory to list and no path | ||
| 145 | // to walk, so anything not compiled in is a 404. | ||
| 146 | func TestStaticServesOnlyTheEmbeddedSet(t *testing.T) { | ||
| 147 | t.Parallel() | ||
| 148 | f := newFixture(t) | ||
| 149 | for _, path := range []string{"/static/", "/static/nope.js", "/static/../go.mod"} { | ||
| 150 | resp := f.gzipGet(t, path, map[string]string{"Accept-Encoding": "identity"}) | ||
| 151 | if resp.StatusCode != http.StatusNotFound { | ||
| 152 | t.Fatalf("%s = %d, want 404", path, resp.StatusCode) | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||