Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }