// SSR (part of package bundler): server-renders the public-page Solid components // to HTML strings by running the (unmodified) client Solid runtime inside a goja // JS engine against a minimal Go-backed DOM (dom.js). Components are authored in // JSX/TSX and compiled to optimized Solid output at build time by the Go-native // compiler (compile_solid.go). The compiled code mounts via solid-js/web's // render into the DOM shim and __serialize walks the shim tree to HTML. // // The rendered HTML is the component's initial markup (data-free at build time, or // rendered with injected __SERVER_DATA__ for ISR); the browser bundle re-renders it // on load (Solid re-render takeover). The runtime ISR entry (RenderBundleWithData) // is imported by the server; the build-time entries are used by the bundler. // // -mta package bundler import ( "crypto/sha256" _ "embed" "encoding/hex" "encoding/json" "fmt" "path/filepath" "strings" "github.com/dop251/goja" esbuild "github.com/evanw/esbuild/pkg/api" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) //go:embed js/ssr/dom.js var domJS string // preludeJS installs the browser globals Solid's client runtime reaches for // that don't exist in goja. They are deliberately inert: no timer or // microtask fires during the synchronous render, and fetch never resolves — // so a component's onMount data load can't run, leaving exactly the // pre-hydration skeleton we want to serialize. const preludeJS = ` var console = { log: function () { __log.apply(null, ["log"].concat(Array.prototype.slice.call(arguments))); }, info: function () { __log.apply(null, ["info"].concat(Array.prototype.slice.call(arguments))); }, warn: function () { __log.apply(null, ["warn"].concat(Array.prototype.slice.call(arguments))); }, error: function () { __log.apply(null, ["error"].concat(Array.prototype.slice.call(arguments))); }, debug: function () {}, }; globalThis.queueMicrotask = function (cb) { (globalThis.__mt || (globalThis.__mt = [])).push(cb); }; globalThis.setTimeout = function () { return 0; }; globalThis.clearTimeout = function () {}; globalThis.setInterval = function () { return 0; }; globalThis.clearInterval = function () {}; globalThis.requestAnimationFrame = function () { return 0; }; globalThis.cancelAnimationFrame = function () {}; globalThis.fetch = function () { return Promise.resolve({ ok: false, status: 0, json: function () { return Promise.resolve(null); }, text: function () { return Promise.resolve(""); } }); }; globalThis._$HY = { events: [], completed: (typeof WeakSet !== "undefined" ? new WeakSet() : null), r: {}, done: true, fe: function () {} }; // Marker so page components can render a lightweight skeleton during SSR and // defer heavy, browser-only UI (charts, PDF, icon fonts) to the client takeover. globalThis.__SSR__ = true; // Minimal Intl shim: goja has no Intl, but some modules construct formatters at // import time. The shim just needs to not throw; real formatting happens on the // client. format() returns the stringified input. if (typeof Intl === "undefined") { globalThis.Intl = { NumberFormat: function () { return { format: function (n) { return String(n); }, formatToParts: function (n) { return [{ type: "literal", value: String(n) }]; } }; }, DateTimeFormat: function () { return { format: function (d) { return String(d); } }; }, }; } ` // Engine is a single goja runtime with the DOM shim installed. It is NOT // safe for concurrent use; the eventual handler keeps a pool of these. type Engine struct { vm *goja.Runtime } // New builds a runtime, installs the Go bridges (__parseHTML, __log), runs // the prelude and the DOM shim, and returns a ready engine. func New() (*Engine, error) { vm := goja.New() if err := vm.Set("__parseHTML", parseHTMLToJSON); err != nil { return nil, err } if err := vm.Set("__log", func(args ...interface{}) { fmt.Println(append([]interface{}{"[ssr]"}, args...)...) }); err != nil { return nil, err } if _, err := vm.RunString(preludeJS); err != nil { return nil, fmt.Errorf("prelude: %w", err) } if _, err := vm.RunString(domJS); err != nil { return nil, fmt.Errorf("dom shim: %w", err) } return &Engine{vm: vm}, nil } // LoadBundle evaluates a bundled Solid entry (see BundleEntry). The entry is // expected to define globalThis.__render. func (e *Engine) LoadBundle(js string) error { _, err := e.vm.RunString(js) return err } // Render invokes the entry's __render() and returns the serialized HTML. func (e *Engine) Render() (string, error) { v, err := e.vm.RunString("__render()") if err != nil { return "", err } return v.String(), nil } // SetServerData installs the page's per-render data as globalThis.__SERVER_DATA__, // which components read (via the frontend serverData() accessor) to render with // real data instead of a skeleton. dataJSON must be a JSON document — it's valid // JS, wrapped in parens so an object literal parses as an expression. Used by the // ISR path (request-time render-with-data, cached); the static skeleton bake sets // nothing, so components fall back to placeholders there. func (e *Engine) SetServerData(dataJSON string) error { if dataJSON == "" { return nil } _, err := e.vm.RunString("globalThis.__SERVER_DATA__ = (" + dataJSON + ");") return err } // BundleEntry bundles an inline JS entry into a single IIFE that goja can run, // resolving bare solid-js* specifiers to the vendored runtime files under // projectRoot/wwwroot/vendor. Any .tsx/.jsx pulled into the graph is Solid- // compiled by the Go-native compiler (Plugin). The generated entry itself is // plain JS — it uses solid-js/web's createComponent rather than JSX — so it needs // no transform. func BundleEntry(entrySource, projectRoot string) (string, error) { js, _, err := bundleEntry(entrySource, projectRoot, false) return js, err } // ssrStubModule is the inert CommonJS module the ssr-stub plugin loads for every // browser-only library under SSR. A permissive Proxy satisfies any named or // default import and any no-op property access, call, construction, or assignment, // so it works for any package without knowing its shape. const ssrStubModule = ` var handler = { get: function (_t, p) { return p === "__esModule" ? true : stub; }, apply: function () { return stub; }, construct: function () { return stub; }, set: function () { return true; }, }; var stub = new Proxy(function () {}, handler); module.exports = stub; ` // bundleEntry is the shared esbuild pass behind BundleEntry. When withMeta is // set it also returns the esbuild metafile JSON, whose "inputs" map lets the // bundler discover (and fingerprint) the source files a page pulled in, for // build-time change detection. Computing the metafile is skipped otherwise. func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile string, err error) { // esbuild's Alias targets and ResolveDir must be absolute: a relative // target like "wwwroot/vendor/solid-js.js" (no leading "./") is read as a // bare package specifier and fails to resolve. Callers pass "." (the // server's cwd), so absolutize here. absRoot, err := filepath.Abs(projectRoot) if err != nil { return "", "", fmt.Errorf("resolve project root: %w", err) } // Absolute vendor roots (kjol runtime first, then app vendor) so SSR resolves // the SAME solid-js the client bundle does. var absVendorDirs []string for _, d := range vendorDirs() { if filepath.IsAbs(d) { absVendorDirs = append(absVendorDirs, d) } else { absVendorDirs = append(absVendorDirs, filepath.Join(absRoot, d)) } } // vendor.json pins each vendored package's exact entrypoint file (see // vendor_plugins.go) because a package's own `exports`/`main`/`module` fields // mis-resolve under NodePaths resolution — e.g. solid-js/web's `module` field // points at dist/server.js (the seroval-based SSR build), not the DOM dev // build. The client bundle avoids that via vendorManifestPlugin; SSR needs the // same pin for the solid-js family it resolves for real (see stubPlugin below). // loadVendorManifest returns absolute entrypoint paths. vendorEntrypoints, err := loadVendorManifest(absVendorDirs) if err != nil { return "", "", fmt.Errorf("loading vendor manifest: %w", err) } // The public pages are Solid/TSX. The .tsx files are Solid-compiled by the // Go-native compiler (Plugin); the compiled output + // the tagged-template pages import from solid-js/web. Resolve the solid runtime // from frontend/vendor (dev DOM builds — SSR renders into a DOM shim, not // renderToString), pinning bare solid-js* specifiers to their vendor.json // entrypoint (same single source of truth as the client bundle) and falling // back to NodePaths for any solid-js* subpath vendor.json doesn't list. Every // OTHER bare import (fontawesome, pdf-lib, pdfjs-dist, chart.js, @solidjs/router, // …) resolves to an inert stub: public pages skip their heavy UI under SSR // (globalThis.__SSR__), so the stub only needs to satisfy the graph. Generating // the stub means no hardcoded list and no stub files to maintain. stubPlugin := esbuild.Plugin{ Name: "ssr-stub", Setup: func(build esbuild.PluginBuild) { build.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { if args.Kind == esbuild.ResolveEntryPoint || filepath.IsAbs(args.Path) || strings.HasPrefix(args.Path, ".") { return esbuild.OnResolveResult{}, nil } if strings.HasPrefix(args.Path, "solid-js") { if target, ok := vendorEntrypoints[args.Path]; ok { return esbuild.OnResolveResult{Path: target}, nil // pinned dev entrypoint (absolute) } return esbuild.OnResolveResult{}, nil // unpinned subpath -> real solid runtime via NodePaths } return esbuild.OnResolveResult{Path: args.Path, Namespace: "ssr-stub"}, nil }) build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: "ssr-stub"}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { contents := ssrStubModule loader := esbuild.LoaderJS return esbuild.OnLoadResult{Contents: &contents, Loader: loader}, nil }) }, } // aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree first; the Go // Solid compiler (Plugin) compiles .tsx before the catch-all stub sees any of // its imports. plugins := []esbuild.Plugin{aliasPlugin(), Plugin(), stubPlugin} result := esbuild.Build(esbuild.BuildOptions{ Stdin: &esbuild.StdinOptions{ Contents: entrySource, ResolveDir: absRoot, Sourcefile: "ssr-entry.js", Loader: esbuild.LoaderJS, }, Bundle: true, Format: esbuild.FormatIIFE, Target: esbuild.ES2017, Platform: esbuild.PlatformBrowser, Conditions: []string{"development"}, NodePaths: absVendorDirs, Plugins: plugins, LogLevel: esbuild.LogLevelSilent, Write: false, Metafile: withMeta, }) if len(result.Errors) > 0 { msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{}) return "", "", fmt.Errorf("esbuild: %s", strings.Join(msgs, "\n")) } if len(result.OutputFiles) == 0 { return "", "", fmt.Errorf("esbuild produced no output") } return string(result.OutputFiles[0].Contents), result.Metafile, nil } // engineCacheVersion is bumped by hand when the SSR engine changes in a way // that affects rendered output but isn't captured by the dom.js/prelude source // below (e.g. a change in serialization in renderer.go). // // "2" — public pages migrated from solid-js/html to React/TSX; SSR now renders // via react-dom/server renderToString instead of the DOM-shim serializer. // "3" — public pages migrated to Solid JSX/TSX (compiled via the in-package Solid JSX compiler, jsx.go); // SSR back on the DOM-shim + serialize path, React prelude shims removed. const engineCacheVersion = "3" // EngineHash fingerprints the SSR engine — the DOM shim, the prelude, and a // manual version. The bundler folds it into its public-page render cache so an // engine change invalidates every cached page (their input files wouldn't have // changed, but their rendered output would). func EngineHash() string { h := sha256.New() h.Write([]byte(engineCacheVersion)) h.Write([]byte{0}) h.Write([]byte(preludeJS)) h.Write([]byte{0}) h.Write([]byte(domJS)) return hex.EncodeToString(h.Sum(nil)) } // ---- HTML parse bridge (x/net/html) ------------------------------------ // jnode is the compact JSON shape dom.js rebuilds shim nodes from. type jnode struct { T string `json:"t"` // "e" element, "t" text, "c" comment N string `json:"n,omitempty"` // element tag name NS string `json:"ns,omitempty"` // "svg" / "math" for foreign content A map[string]string `json:"a,omitempty"` // attributes C []*jnode `json:"c,omitempty"` // children D string `json:"d,omitempty"` // text / comment data } // parseHTMLToJSON parses an innerHTML fragment using template-content // semantics (so gets its implicit , void elements close, and // entities decode per the HTML5 spec) and returns it as a JSON node array. func parseHTMLToJSON(fragment string) string { ctx := &html.Node{Type: html.ElementNode, DataAtom: atom.Template, Data: "template"} nodes, err := html.ParseFragment(strings.NewReader(fragment), ctx) if err != nil { return "[]" } roots := make([]*jnode, 0, len(nodes)) for _, n := range nodes { if jn := convert(n); jn != nil { roots = append(roots, jn) } } b, err := json.Marshal(roots) if err != nil { return "[]" } return string(b) } func convert(n *html.Node) *jnode { switch n.Type { case html.ElementNode: jn := &jnode{T: "e", N: n.Data} if n.Namespace == "svg" || n.Namespace == "math" { jn.NS = n.Namespace } if len(n.Attr) > 0 { jn.A = make(map[string]string, len(n.Attr)) for _, a := range n.Attr { key := a.Key if a.Namespace != "" { key = a.Namespace + ":" + a.Key } jn.A[key] = a.Val } } for c := n.FirstChild; c != nil; c = c.NextSibling { if cj := convert(c); cj != nil { jn.C = append(jn.C, cj) } } return jn case html.TextNode: return &jnode{T: "t", D: n.Data} case html.CommentNode: return &jnode{T: "c", D: n.Data} } return nil }