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 }