Files
kjol/go/webbundler/renderer.go

229 lines
7.5 KiB
Go

package webbundler
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// devPageRenderers caches one dev Renderer per public-page SSR entry so repeated
// requests reuse the cached HTML until frontend/src changes.
var devPageRenderers sync.Map // entry string -> *Renderer
// DevRenderPublicPage live-renders a public page's SSR body for the dev server,
// re-rendering only when frontend/src changes (Renderer's dev-mode mtime check).
// module is relative to frontend/src (e.g. "pages/public/AboutUs.tsx"), component
// is the exported body name, and currentPath drives PublicLayout's active nav.
// The server runs from the repo root, so projectRoot is ".".
func DevRenderPublicPage(module, component, currentPath string) (string, error) {
fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
entry := ssrEntrySolid(fullModule, component, currentPath)
r, _ := devPageRenderers.LoadOrStore(entry, NewRenderer(".", entry, true))
return r.(*Renderer).HTML()
}
// devPublicBundles caches one compiled SSR bundle per ISR entry, re-bundled only
// when frontend/src changes. Unlike devPageRenderers (which caches the finished
// data-free HTML), ISR renders with fresh data each request, so we cache the
// bundle and re-render it per request.
var devPublicBundles sync.Map // entry string -> *devBundle
type devBundle struct {
root, entry string
mu sync.Mutex
bundle string
builtAt time.Time
ok bool
}
func (b *devBundle) get() (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.ok && !newestSourceMtime(b.root).After(b.builtAt) {
return b.bundle, nil
}
bundled, err := BundleEntry(b.entry, b.root)
if err != nil {
return "", err
}
b.bundle, b.builtAt, b.ok = bundled, newestSourceMtime(b.root), true
return bundled, nil
}
// DevRenderPublicPageWithData live-renders an ISR public page in dev: it bundles
// the page from current source (re-bundling only when frontend/src changes) and
// renders it with the supplied server data. So editing an ISR page's component
// shows on the next reload even with JS disabled — only the Go data-loading code
// (the real server component) still needs a server restart.
func DevRenderPublicPageWithData(module, component, currentPath, dataJSON string) (string, error) {
fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
entry := ssrEntrySolid(fullModule, component, currentPath)
bv, _ := devPublicBundles.LoadOrStore(entry, &devBundle{root: ".", entry: entry})
bundle, err := bv.(*devBundle).get()
if err != nil {
return "", err
}
return RenderBundleWithData(bundle, dataJSON)
}
// RewarmDevPublicPages re-renders, in the background, every public page whose dev
// SSR has already been requested — so after an edit the fresh no-JS SSR is ready
// before the next reload instead of being rendered lazily on it. The dev watcher
// calls this on a source change. Each Renderer only actually re-renders if
// frontend/src changed, so redundant calls are cheap; errors are left for the
// request path to surface.
func RewarmDevPublicPages() {
devPageRenderers.Range(func(_, v any) bool {
go v.(*Renderer).HTML()
return true
})
devPublicBundles.Range(func(_, v any) bool {
go v.(*devBundle).get()
return true
})
}
// newestSourceMtime returns the newest modification time under
// projectRoot/frontend/src, or the zero time if the tree can't be walked
// (treated as "unchanged"). Drives the dev-mode rebuild checks.
func newestSourceMtime(projectRoot string) time.Time {
var newest time.Time
srcDir := filepath.Join(projectRoot, "frontend", "src")
filepath.WalkDir(srcDir, func(_ string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if info, e := d.Info(); e == nil && info.ModTime().After(newest) {
newest = info.ModTime()
}
return nil
})
return newest
}
// Renderer renders one public-page entry to HTML and caches the result.
// Because SSR output is data-free it is identical every request, so a cached
// string can be reused indefinitely. In dev the cache is invalidated when any
// file under frontend/src changes (a cheap mtime scan), so source edits show
// up on reload without a restart and without paying the ~1s bundle+render on
// every request.
type Renderer struct {
projectRoot string
entry string
dev bool
mu sync.Mutex
cached string
builtAt time.Time // newest source mtime seen at last build
ok bool
}
func NewRenderer(projectRoot, entry string, dev bool) *Renderer {
return &Renderer{projectRoot: projectRoot, entry: entry, dev: dev}
}
// HTML returns the rendered markup, rebuilding only when necessary.
func (r *Renderer) HTML() (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.ok {
if !r.dev {
return r.cached, nil
}
if !r.maxSourceMtime().After(r.builtAt) {
return r.cached, nil // sources unchanged since last build
}
}
out, err := renderOnce(r.entry, r.projectRoot)
if err != nil {
return "", err
}
r.cached, r.builtAt, r.ok = out, r.maxSourceMtime(), true
return out, nil
}
// maxSourceMtime returns the newest modification time under frontend/src.
func (r *Renderer) maxSourceMtime() time.Time { return newestSourceMtime(r.projectRoot) }
func renderOnce(entry, projectRoot string) (string, error) {
bundle, err := BundleEntry(entry, projectRoot)
if err != nil {
return "", err
}
eng, err := New()
if err != nil {
return "", err
}
if err := eng.LoadBundle(bundle); err != nil {
return "", err
}
return eng.Render()
}
// RenderBundleWithData runs a pre-bundled render entry (baked into
// public_pages.gen.go by RenderEntryFull) in goja with server data injected, and
// returns the rendered HTML. This is the runtime ISR path — no esbuild, no
// source files on disk, so it works in a single-binary release.
func RenderBundleWithData(bundleJS, dataJSON string) (string, error) {
eng, err := New()
if err != nil {
return "", err
}
if err := eng.SetServerData(dataJSON); err != nil {
return "", err
}
if err := eng.LoadBundle(bundleJS); err != nil {
return "", err
}
return eng.Render()
}
// RenderEntryFull bundles and renders one SSR entry, returning the rendered
// (data-free) skeleton HTML, the bundled JS, and the on-disk input files esbuild
// pulled in. The bundler (genssr.go) uses this at build time: it
// bakes the HTML for every page and, for dynamic (ISR) pages, also bakes the JS
// so the server can re-render it with data at request time (RenderBundleWithData)
// — no esbuild or source files on disk in production. The inputs feed
// build-time change detection.
func RenderEntryFull(entry, projectRoot string) (html, js string, inputs []string, err error) {
bundled, metafile, err := bundleEntry(entry, projectRoot, true)
if err != nil {
return "", "", nil, err
}
eng, err := New()
if err != nil {
return "", "", nil, err
}
if err := eng.LoadBundle(bundled); err != nil {
return "", "", nil, err
}
out, err := eng.Render()
if err != nil {
return "", "", nil, err
}
return out, bundled, metafileInputs(metafile), nil
}
// metafileInputs returns the existing-on-disk input files from an esbuild
// metafile (paths are relative to the build's working directory). Virtual
// inputs like the inline entry are skipped — they don't os.Stat.
func metafileInputs(metafile string) []string {
var mf struct {
Inputs map[string]json.RawMessage `json:"inputs"`
}
if metafile == "" || json.Unmarshal([]byte(metafile), &mf) != nil {
return nil
}
out := make([]string, 0, len(mf.Inputs))
for p := range mf.Inputs {
if _, err := os.Stat(p); err == nil {
out = append(out, filepath.ToSlash(p))
}
}
return out
}