package jsbundler // 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 }