Files
kjol/go/bundler/ssr.go

341 lines
14 KiB
Go

// 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)
}
vendorDir := filepath.Join(absRoot, "frontend", "vendor")
// 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).
vendorEntrypoints, err := loadVendorManifest(vendorDir)
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 {
abs, err := filepath.Abs(filepath.Join(vendorDir, filepath.FromSlash(target)))
if err != nil {
return esbuild.OnResolveResult{}, err
}
return esbuild.OnResolveResult{Path: filepath.ToSlash(abs)}, nil // pinned dev entrypoint
}
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
})
},
}
// The Go Solid compiler (Plugin) compiles .tsx before the catch-all stub sees
// any of its imports.
plugins := []esbuild.Plugin{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: []string{vendorDir},
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 <table> gets its implicit <tbody>, 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
}