Files
kjol/bundler/genssr.go

155 lines
6.3 KiB
Go

package bundler
// Public-page SSR code generation. For each page in the manifest
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component
// to its data-free skeleton HTML — at build time, via the same in-package goja
// engine (ssr.go/renderer.go) the server re-runs for ISR — and bakes it into the
// Go registry:
//
// - internal/handlers/public_pages.gen.go routes + <title> + rendered body
//
// The server then just wraps each baked body in the document shell and serves
// it (internal/handlers/public_ssr.go); the browser bundle takes over on load.
//
// The env badge is intentionally absent from the rendered markup: this SSR
// render doesn't define esbuild's __ENV_TYPE__ (see ssr.go), so env.ts reads ""
// and the badge renders nothing here. The client takeover bundle bakes the real
// compile-time environment, so the badge appears after takeover.
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"time"
)
// writeGoRegistry renders each page's SSR skeleton and writes the Go registry
// (internal/handlers/public_pages.gen.go). Rendering reads frontend/src and
// wwwroot/vendor relative to the project root, so the bundler must run from
// there (it always does).
//
// Rendering is cached per page (tmp/ssr-cache.json) keyed by the page's entry
// source + the content hashes of every file esbuild bundled into it, so a build
// only re-renders pages whose sources actually changed. Cache misses render
// concurrently (one goja runtime per worker). See ssrcache.go.
func writeGoRegistry(defs []pageDef) error {
start := time.Now()
engine := EngineHash()
prev := loadSSRCache()
reuse := prev.Engine == engine // an engine change invalidates every page
hasher := newFileHasher()
next := ssrCache{Engine: engine, Pages: make(map[string]ssrCacheEntry, len(defs))}
bodies := make([]string, len(defs))
renderJSs := make([]string, len(defs)) // bundled render entry, baked for dynamic (ISR) pages
entries := make([]string, len(defs))
cached := make([]bool, len(defs))
// First pass (cheap, serial): reuse unchanged pages, collect the rest.
var misses []renderJob
for i, d := range defs {
module := filepath.ToSlash(path.Join("frontend", "src", d.Module))
entries[i] = ssrEntrySolid(module, d.Component, d.Path)
if reuse {
if ce, ok := prev.Pages[d.Path]; ok && ce.EntryHash == pageEntryHash(entries[i], d.Dynamic) && inputsUnchanged(ce.Inputs, hasher) {
bodies[i] = ce.HTML
renderJSs[i] = ce.RenderJS
cached[i] = true
next.Pages[d.Path] = ce // carry the fingerprint forward
continue
}
}
misses = append(misses, renderJob{idx: i, path: d.Path, component: d.Component, entry: entries[i]})
}
// Second pass (parallel): render the misses, then fingerprint their inputs.
results, err := renderMisses(misses)
if err != nil {
return err
}
for _, r := range results {
bodies[r.idx] = r.html
// Bake the bundled render entry only for dynamic pages — the server
// re-runs it with data at request time (ISR), so no esbuild or source
// files are needed at runtime.
js := ""
if defs[r.idx].Dynamic {
js = r.js
}
renderJSs[r.idx] = js
next.Pages[r.path] = ssrCacheEntry{
EntryHash: pageEntryHash(entries[r.idx], defs[r.idx].Dynamic),
Inputs: hashInputs(r.inputs, hasher),
HTML: r.html,
RenderJS: js,
}
}
// Emit the registry in manifest order.
var b strings.Builder
b.WriteString("// Code generated by cmd/bundle; DO NOT EDIT.\n")
b.WriteString("// Source: frontend/src/pages/public/pages.ts\n")
b.WriteString("//\n")
b.WriteString("// Each html field is the page's data-free SSR skeleton, rendered from its\n")
b.WriteString("// Solid component at bundle time. The browser bundle re-renders it on load.\n\n")
b.WriteString("package handlers\n\n")
b.WriteString("var publicPages = []publicPage{\n")
for i, d := range defs {
fmt.Fprintf(&b, "\t{route: %q, title: %q, module: %q, component: %q, html: %q, renderJS: %q},\n", goRoute(d.Path), d.Title, d.Module, d.Component, bodies[i], renderJSs[i])
status := "rendered"
if cached[i] {
status = "cached"
}
fmt.Printf(" %-20s %s (%s, %d bytes)\n", d.Path, d.Component, status, len(bodies[i]))
}
b.WriteString("}\n")
if err := os.WriteFile(filepath.Join("internal", "handlers", "public_pages.gen.go"), []byte(b.String()), 0644); err != nil {
return err
}
saveSSRCache(next)
fmt.Printf(" SSR: %d rendered, %d cached in %s\n", len(misses), len(defs)-len(misses), time.Since(start).Round(time.Millisecond))
return nil
}
// pageEntryHash keys the render cache. It folds in the page's `dynamic` flag so
// toggling ISR on/off re-renders the page (a dynamic page also bakes its render
// JS, which an entry-only hash wouldn't notice changed).
func pageEntryHash(entry string, dynamic bool) string {
if dynamic {
return hashString(entry + "\x00dynamic")
}
return hashString(entry)
}
// ssrEntrySolid builds the goja entry for one page: import its body component,
// wrap it in PublicLayout (currentPath = the page's path), render into a detached
// DOM-shim root with solid-js/web's render, and serialize. Mirrors the client
// takeover (public.tsx), which wraps the same body in the same layout — so the
// server markup and post-takeover markup match.
//
// The entry is written as plain JS using createComponent (what compiled Solid JSX
// would emit) rather than JSX, so it needs no transform; the imported .tsx page +
// layout ARE Solid-compiled by the Go-native compiler (Plugin).
//
// esbuild's __ENV_TYPE__ define is not applied to this SSR build, so env.ts
// yields "" and the EnvBadge in PublicLayout renders nothing during SSR; the
// client takeover bundle carries the baked value on load.
func ssrEntrySolid(module, component, currentPath string) string {
return fmt.Sprintf("import { render, createComponent } from \"solid-js/web\";\n"+
"import { PublicLayout } from \"./frontend/src/pages/public/PublicLayout.tsx\";\n"+
"import { %[1]s } from \"./%[2]s\";\n"+
"globalThis.__render = function () {\n"+
"\tconst root = document.createElement(\"div\");\n"+
"\tconst dispose = render(function () {\n"+
"\t\treturn createComponent(PublicLayout, { currentPath: %[3]q, get children() { return createComponent(%[1]s, {}); } });\n"+
"\t}, root);\n"+
"\tconst out = globalThis.__serialize(root);\n"+
"\tdispose();\n"+
"\treturn out;\n"+
"};", component, module, currentPath)
}