Skip to content

Commit f3f6458

Browse files
author
mirkobrombin
committed
feat: implement in-memory caching for static files with ETag support
1 parent b63615b commit f3f6458

File tree

2 files changed

+201
-52
lines changed

2 files changed

+201
-52
lines changed

internal/server/handler.go

Lines changed: 138 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package server
22

33
import (
4+
"bytes"
5+
"crypto/md5"
6+
"encoding/hex"
47
"fmt"
58
"net/http"
69
"net/http/httputil"
@@ -15,6 +18,140 @@ import (
1518
"github.com/mirkobrombin/goup/internal/server/middleware"
1619
)
1720

21+
// Memory cache for static files.
22+
var staticFileCache sync.Map
23+
24+
type cachedFile struct {
25+
content []byte
26+
modTime time.Time
27+
etag string
28+
}
29+
30+
var (
31+
sharedProxyMap = make(map[string]*httputil.ReverseProxy)
32+
sharedProxyMapMu sync.Mutex
33+
defaultTransport = &http.Transport{}
34+
35+
globalBytePool = &byteSlicePool{
36+
pool: sync.Pool{
37+
New: func() interface{} {
38+
return make([]byte, 32*1024)
39+
},
40+
},
41+
}
42+
)
43+
44+
// getSharedReverseProxy returns a shared ReverseProxy for the given backend URL.
45+
func getSharedReverseProxy(rawURL string) (*httputil.ReverseProxy, error) {
46+
sharedProxyMapMu.Lock()
47+
defer sharedProxyMapMu.Unlock()
48+
49+
if rp, ok := sharedProxyMap[rawURL]; ok {
50+
return rp, nil
51+
}
52+
53+
parsedURL, err := url.Parse(rawURL)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
rp := httputil.NewSingleHostReverseProxy(parsedURL)
59+
rp.Transport = defaultTransport
60+
rp.BufferPool = globalBytePool
61+
62+
sharedProxyMap[rawURL] = rp
63+
return rp, nil
64+
}
65+
66+
type byteSlicePool struct {
67+
pool sync.Pool
68+
}
69+
70+
func (b *byteSlicePool) Get() []byte {
71+
return b.pool.Get().([]byte)
72+
}
73+
74+
func (b *byteSlicePool) Put(buf []byte) {
75+
if cap(buf) == 32*1024 {
76+
b.pool.Put(buf[:32*1024])
77+
}
78+
}
79+
80+
// computeETag returns an ETag string for the given data.
81+
func computeETag(data []byte) string {
82+
sum := md5.Sum(data)
83+
return `"` + hex.EncodeToString(sum[:]) + `"`
84+
}
85+
86+
// memoryCachedFileServer returns a handler that serves (and caches) static files from memory.
87+
func memoryCachedFileServer(dir string, headers map[string]string) http.Handler {
88+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+
filePath := strings.TrimPrefix(r.URL.Path, "/")
90+
fullPath := dir + "/" + filePath
91+
92+
// Try to load from memory cache
93+
cached, ok := staticFileCache.Load(fullPath)
94+
if ok {
95+
cf := cached.(*cachedFile)
96+
97+
// Check ETag (If-None-Match) and If-Modified-Since
98+
if match := r.Header.Get("If-None-Match"); match != "" && match == cf.etag {
99+
w.WriteHeader(http.StatusNotModified)
100+
return
101+
}
102+
if sinceStr := r.Header.Get("If-Modified-Since"); sinceStr != "" {
103+
if since, err := time.Parse(http.TimeFormat, sinceStr); err == nil {
104+
if !cf.modTime.IsZero() && cf.modTime.Before(since.Add(1*time.Second)) {
105+
w.WriteHeader(http.StatusNotModified)
106+
return
107+
}
108+
}
109+
}
110+
111+
w.Header().Set("ETag", cf.etag)
112+
113+
// Use bytes.NewReader here to implement io.Seeker properly
114+
http.ServeContent(w, r, filePath, cf.modTime, bytes.NewReader(cf.content))
115+
return
116+
}
117+
118+
// File not in cache, load from disk
119+
file, err := http.Dir(dir).Open(filePath)
120+
if err != nil {
121+
http.NotFound(w, r)
122+
return
123+
}
124+
defer file.Close()
125+
126+
stat, err := file.Stat()
127+
if err != nil {
128+
http.NotFound(w, r)
129+
return
130+
}
131+
132+
buf := make([]byte, stat.Size())
133+
_, err = file.Read(buf)
134+
if err != nil {
135+
http.NotFound(w, r)
136+
return
137+
}
138+
139+
// Compute ETag and store in cache
140+
etag := computeETag(buf)
141+
cf := &cachedFile{
142+
content: buf,
143+
modTime: stat.ModTime(),
144+
etag: etag,
145+
}
146+
staticFileCache.Store(fullPath, cf)
147+
148+
w.Header().Set("ETag", etag)
149+
150+
// Serve using a bytes.Reader to satisfy ServeContent
151+
http.ServeContent(w, r, filePath, cf.modTime, bytes.NewReader(cf.content))
152+
})
153+
}
154+
18155
// createHandler creates the HTTP handler for a site configuration.
19156
func createHandler(conf config.SiteConfig, log *logger.Logger, identifier string, globalMwManager *middleware.MiddlewareManager) (http.Handler, error) {
20157
var handler http.Handler
@@ -33,10 +170,9 @@ func createHandler(conf config.SiteConfig, log *logger.Logger, identifier string
33170

34171
} else {
35172
// Serve static files from the root directory.
36-
fs := http.FileServer(http.Dir(conf.RootDirectory))
37173
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38174
addCustomHeaders(w, conf.CustomHeaders)
39-
fs.ServeHTTP(w, r)
175+
memoryCachedFileServer(conf.RootDirectory, conf.CustomHeaders).ServeHTTP(w, r)
40176
})
41177
}
42178

@@ -79,53 +215,3 @@ func addCustomHeaders(w http.ResponseWriter, headers map[string]string) {
79215

80216
w.Header().Set("Access-Control-Expose-Headers", strings.Join(exposeHeaders, ", "))
81217
}
82-
83-
var (
84-
sharedProxyMap = make(map[string]*httputil.ReverseProxy)
85-
sharedProxyMapMu sync.Mutex
86-
defaultTransport = &http.Transport{}
87-
88-
globalBytePool = &byteSlicePool{
89-
pool: sync.Pool{
90-
New: func() interface{} {
91-
return make([]byte, 32*1024)
92-
},
93-
},
94-
}
95-
)
96-
97-
type byteSlicePool struct {
98-
pool sync.Pool
99-
}
100-
101-
func (b *byteSlicePool) Get() []byte {
102-
return b.pool.Get().([]byte)
103-
}
104-
105-
func (b *byteSlicePool) Put(buf []byte) {
106-
if cap(buf) == 32*1024 {
107-
b.pool.Put(buf[:32*1024])
108-
}
109-
}
110-
111-
// getSharedReverseProxy returns a shared ReverseProxy for the given backend URL.
112-
func getSharedReverseProxy(rawURL string) (*httputil.ReverseProxy, error) {
113-
sharedProxyMapMu.Lock()
114-
defer sharedProxyMapMu.Unlock()
115-
116-
if rp, ok := sharedProxyMap[rawURL]; ok {
117-
return rp, nil
118-
}
119-
120-
parsedURL, err := url.Parse(rawURL)
121-
if err != nil {
122-
return nil, err
123-
}
124-
125-
rp := httputil.NewSingleHostReverseProxy(parsedURL)
126-
rp.Transport = defaultTransport
127-
rp.BufferPool = globalBytePool
128-
129-
sharedProxyMap[rawURL] = rp
130-
return rp, nil
131-
}

internal/server/handler_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,66 @@ func TestCreateHandler_ProxyPass(t *testing.T) {
106106
t.Errorf("Expected body %q, got %q", expectedBody, w.Body.String())
107107
}
108108
}
109+
110+
func TestCreateHandler_Static_CachingETag(t *testing.T) {
111+
tmpDir, err := os.MkdirTemp("", "test_static_cache")
112+
if err != nil {
113+
t.Fatalf("Error creating temp dir: %v", err)
114+
}
115+
defer os.RemoveAll(tmpDir)
116+
117+
testFilePath := filepath.Join(tmpDir, "cached.txt")
118+
content := []byte("Cached content")
119+
err = os.WriteFile(testFilePath, content, 0644)
120+
if err != nil {
121+
t.Fatalf("Error creating test file: %v", err)
122+
}
123+
124+
conf := config.SiteConfig{
125+
Domain: "example-cache.com",
126+
RootDirectory: tmpDir,
127+
CustomHeaders: map[string]string{},
128+
RequestTimeout: 60,
129+
}
130+
131+
testLogger, err := logger.NewLogger("test_handler_cache", nil)
132+
if err != nil {
133+
t.Fatalf("Error creating logger: %v", err)
134+
}
135+
testLogger.SetOutput(httptest.NewRecorder())
136+
137+
handler, err := createHandler(conf, testLogger, "test-cache", &middleware.MiddlewareManager{})
138+
if err != nil {
139+
t.Fatalf("Error creating handler: %v", err)
140+
}
141+
142+
// First request to load file into cache and retrieve ETag
143+
req := httptest.NewRequest("GET", "http://example-cache.com/cached.txt", nil)
144+
w := httptest.NewRecorder()
145+
handler.ServeHTTP(w, req)
146+
147+
if w.Code != http.StatusOK {
148+
t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code)
149+
}
150+
151+
etag := w.Header().Get("ETag")
152+
if etag == "" {
153+
t.Errorf("Expected ETag header to be set, got empty")
154+
}
155+
if w.Body.String() != string(content) {
156+
t.Errorf("Expected body %q, got %q", string(content), w.Body.String())
157+
}
158+
159+
// Second request with If-None-Match
160+
req2 := httptest.NewRequest("GET", "http://example-cache.com/cached.txt", nil)
161+
req2.Header.Set("If-None-Match", etag)
162+
w2 := httptest.NewRecorder()
163+
handler.ServeHTTP(w2, req2)
164+
165+
if w2.Code != http.StatusNotModified {
166+
t.Errorf("Expected status code %d when sending ETag, got %d", http.StatusNotModified, w2.Code)
167+
}
168+
if w2.Body.Len() != 0 {
169+
t.Errorf("Expected empty body on 304, got %q", w2.Body.String())
170+
}
171+
}

0 commit comments

Comments
 (0)