Add js web stuff to landing page + documentation
This commit is contained in:
347
go/jsbundler/ssr.go
Normal file
347
go/jsbundler/ssr.go
Normal file
@@ -0,0 +1,347 @@
|
||||
// SSR (part of package jsbundler): 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 jsbundler
|
||||
|
||||
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 JS 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 <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
|
||||
}
|
||||
Reference in New Issue
Block a user