193 lines
5.0 KiB
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
|
|
}
|