package webbundler // 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 + + 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) }