Initial add backend stuff
This commit is contained in:
228
bundler/renderer.go
Normal file
228
bundler/renderer.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package bundler
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user