Files
kjol/go/webbundler/ssrcache.go

193 lines
5.0 KiB
Go

package webbundler
// Build-time caching + parallelism for public-page SSR (see genssr.go).
//
// Rendering a page means bundling it with esbuild and running it through a goja
// runtime — too slow to repeat for every page on every build when nothing
// changed. So each render records the source files esbuild pulled in (from the
// metafile) and their content hashes; a later build reuses the cached HTML when
// the page's entry and every input file are byte-identical. Cache misses are
// rendered concurrently, one goja runtime per worker.
//
// The cache lives under tmp/ (gitignored) and is purely an optimization: any
// read/parse/write error just falls back to rendering.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
)
// ssrCachePath is the on-disk render cache (gitignored via tmp/).
var ssrCachePath = filepath.Join("tmp", "ssr-cache.json")
// ssrCacheEntry fingerprints one rendered page.
type ssrCacheEntry struct {
EntryHash string `json:"entry"` // hash of the goja entry source
Inputs map[string]string `json:"inputs"` // input file path -> content hash
HTML string `json:"html"` // the rendered, baked body
RenderJS string `json:"renderJS,omitempty"` // bundled JS, baked for dynamic (ISR) pages
}
// ssrCache is the whole render cache, keyed by page URL path.
type ssrCache struct {
Engine string `json:"engine"` // EngineHash() at write time
Pages map[string]ssrCacheEntry `json:"pages"`
}
func loadSSRCache() ssrCache {
data, err := os.ReadFile(ssrCachePath)
if err != nil {
return ssrCache{}
}
var c ssrCache
if json.Unmarshal(data, &c) != nil || c.Pages == nil {
return ssrCache{}
}
return c
}
func saveSSRCache(c ssrCache) {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(ssrCachePath), 0o755); err != nil {
return
}
_ = os.WriteFile(ssrCachePath, data, 0o644)
}
func hashString(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// fileHasher memoizes content hashes within a single build so files shared by
// several pages (the vendored Solid runtime, PublicLayout, shared UI) are read
// and hashed once. Safe for concurrent use.
type fileHasher struct {
mu sync.Mutex
m map[string]string // path -> hex hash; "" records a read failure
}
func newFileHasher() *fileHasher { return &fileHasher{m: map[string]string{}} }
// hash returns the file's content hash and whether it was readable.
func (f *fileHasher) hash(path string) (string, bool) {
f.mu.Lock()
if h, ok := f.m[path]; ok {
f.mu.Unlock()
return h, h != ""
}
f.mu.Unlock()
h := ""
if data, err := os.ReadFile(path); err == nil {
sum := sha256.Sum256(data)
h = hex.EncodeToString(sum[:])
}
f.mu.Lock()
f.m[path] = h
f.mu.Unlock()
return h, h != ""
}
// inputsUnchanged reports whether every recorded input still hashes the same.
// A page can't gain a new dependency without editing one of these files, so an
// all-match means the bundle (and thus the rendered output) is identical.
func inputsUnchanged(old map[string]string, h *fileHasher) bool {
if len(old) == 0 {
return false
}
for path, want := range old {
got, ok := h.hash(path)
if !ok || got != want {
return false
}
}
return true
}
func hashInputs(paths []string, h *fileHasher) map[string]string {
m := make(map[string]string, len(paths))
for _, p := range paths {
if sum, ok := h.hash(p); ok {
m[p] = sum
}
}
return m
}
// renderJob / renderResult carry a cache-miss page through the worker pool.
type renderJob struct {
idx int
path string
component string
entry string
}
type renderResult struct {
idx int
path string
html string
js string // bundled render entry (baked for dynamic/ISR pages)
inputs []string
}
// renderMisses renders the given jobs concurrently — one goja runtime per
// worker, capped at NumCPU — and returns their results in input order. On the
// first render error it stops reporting and returns that error.
func renderMisses(jobs []renderJob) ([]renderResult, error) {
if len(jobs) == 0 {
return nil, nil
}
limit := runtime.NumCPU()
if limit < 1 {
limit = 1
}
if limit > len(jobs) {
limit = len(jobs)
}
results := make([]renderResult, len(jobs))
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
for k := range jobs {
wg.Add(1)
go func(k int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
j := jobs[k]
html, js, inputs, err := RenderEntryFull(j.entry, ".")
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("rendering %s (%s): %w", j.path, j.component, err)
}
mu.Unlock()
return
}
results[k] = renderResult{idx: j.idx, path: j.path, html: html, js: js, inputs: inputs}
}(k)
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
return results, nil
}