restructure project, add claudemd

This commit is contained in:
2026-07-08 16:36:17 -04:00
parent a7964f9410
commit 2a5fbffaa2
315 changed files with 81075 additions and 0 deletions

147
go/bundler/build.go Normal file
View File

@@ -0,0 +1,147 @@
// Package bundler is the frontend build system: it drives esbuild's Go API for
// JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native
// compilers (no Node, no Babel, no goja on the build path), and bakes the
// public-page SSR (which does still use goja to execute components). The
// `cmd/bundle` command is a thin CLI wrapper over this package.
//
// The package also carries the runtime SSR entry (RenderBundleWithData, in
// ssr.go/renderer.go) that the server imports for ISR.
//
// Pipeline stages live in sibling files:
//
// js.go esbuild-driven JS bundling + sourcemap fixup
// export_shim.go default/named export shim plugin
// import_check.go .js-extension import validation
// css.go Tailwind compile driver + minify
// tailwind.go official Tailwind v4 compiler run in goja
// candidates.go utility-class candidate scanner (feeds Tailwind's build())
// genssr.go public-page SSR bake + Go registry generation
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
// watch.go poll-and-rebuild watch loop
package bundler
import (
"fmt"
"strings"
"sync"
"time"
)
const frontendDir = "frontend"
const outputDir = "wwwroot"
type bundleStats struct {
files int
bytes int
}
// Build runs the full one-shot build: FA icon subset, public-route generation,
// the JS/CSS bundles, and the SSR bake, printing a stats summary.
func Build() error {
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
// which lets misnamed specifiers slip through. Catch them up front so the
// import path always names the file that actually exists on disk.
if violations := checkImportExtensions(); len(violations) > 0 {
reportImportViolations(violations)
return fmt.Errorf("%d import-extension violation(s)", len(violations))
}
buildStart := time.Now()
fmt.Println("Generating FA icon subset...")
if err := generateFAIcons(); err != nil {
return fmt.Errorf("FA icon generation failed: %w", err)
}
fmt.Println("Generating public routes...")
routesStart := time.Now()
if err := generatePublicRoutes(); err != nil {
return fmt.Errorf("public route generation failed: %w", err)
}
routesDur := time.Since(routesStart)
// The four bundles are independent — distinct output files, shared read-only
// inputs plus the generated files above — so run them in parallel. The Go
// Solid compiler and Go Tailwind engine are both stateless per call.
fmt.Println("Bundling JS + CSS...")
results := runBundlesParallel()
for _, r := range results {
if r.err != nil {
return fmt.Errorf("%s failed: %w", r.name, r.err)
}
}
// One column layout drives header, rows, and footer so every field lines up.
// Files is %6d because candidate counts run into the thousands (a %3d would
// overflow and shift Size/Time right on the CSS rows). Total width = 55.
sep := strings.Repeat("-", 55)
fmt.Println()
fmt.Printf("%-27s %6s %8s %9s\n", "Bundle", "Files", "Size", "Time")
fmt.Println(sep)
for _, r := range results {
printStats(r.name, r.stats, r.dur)
}
fmt.Println(sep)
// %-46s%9s places the time in the same field (cols 47-55) as the bundle rows.
fmt.Printf("%-46s%9s\n", "Public routes (SSR)", formatDuration(routesDur))
fmt.Printf("%-46s%9s\n", "Total build time", formatDuration(time.Since(buildStart)))
fmt.Println()
fmt.Println("Done!")
return nil
}
type bundleResult struct {
name string
stats bundleStats
dur time.Duration
err error
}
// runBundlesParallel runs the four output bundles concurrently and returns their
// results in a stable order. Per-bundle durations overlap, so they won't sum to
// wall-clock — that's the point.
func runBundlesParallel() []bundleResult {
jobs := []struct {
name string
fn func() (bundleStats, error)
}{
{"bundle.min.js", bundleJS},
{"public.bundle.min.js", bundlePublicJS},
{"bundle.min.css", bundleSPACSS},
{"public.bundle.min.css", bundlePublicCSS},
}
results := make([]bundleResult, len(jobs))
var wg sync.WaitGroup
for i, j := range jobs {
wg.Add(1)
go func(i int, name string, fn func() (bundleStats, error)) {
defer wg.Done()
t := time.Now()
s, err := fn()
results[i] = bundleResult{name: name, stats: s, dur: time.Since(t), err: err}
}(i, j.name, j.fn)
}
wg.Wait()
return results
}
func printStats(name string, s bundleStats, d time.Duration) {
fmt.Printf("%-27s %6d %8s %9s\n", name, s.files, formatSize(s.bytes), formatDuration(d))
}
// formatDuration renders a build-phase duration compactly: sub-second in ms,
// otherwise seconds with two decimals.
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.2fs", d.Seconds())
}
func formatSize(bytes int) string {
if bytes >= 1024*1024 {
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
}
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
}

399
go/bundler/compile_solid.go Normal file
View File

@@ -0,0 +1,399 @@
package bundler
// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja).
//
// Pipeline: esbuild strips TS types (JSX preserved), then this package parses the
// JSX trees out of the JS and rewrites each into Solid's dom-expressions runtime
// output (template cloning + fine-grained _$insert/_$effect/_$createComponent).
// JS expressions inside `{...}` are captured as opaque strings and, where they
// may contain nested JSX, recompiled recursively — so we never need a full JS
// parser, only a JSX-aware scanner.
//
// This file is the PARSER (JSX text -> tree). Codegen lives in compile_solid_gen.go.
import (
"fmt"
"strings"
)
type jsxKind int
const (
jsxElement jsxKind = iota // lowercase tag -> real DOM element (templated)
jsxComponent // Capitalized/dotted tag -> _$createComponent
jsxFragment // <>...</>
jsxText // literal character data between tags
jsxExpr // {expr} — raw JS, may itself contain JSX
)
type attrKind int
const (
attrStatic attrKind = iota // name="literal" or bare boolean
attrExpr // name={expr}
attrSpread // {...expr}
)
type jsxAttr struct {
kind attrKind
name string
value string // literal string value (attrStatic with a value)
expr string // JS expression (attrExpr) or spread source (attrSpread)
boolt bool // bare boolean attribute (attrStatic, no `=`)
}
type jsxNode struct {
kind jsxKind
tag string
attrs []jsxAttr
children []jsxNode
text string // jsxText
expr string // jsxExpr (raw, may contain nested JSX)
marker bool // codegen: this dynamic child needs a `<!>` insert anchor
}
// parseJSX parses a JSX element/fragment beginning at src[i] == '<'. It returns
// the node and the index just past the element's closing `>`.
func parseJSX(src string, i int) (jsxNode, int, error) {
n := len(src)
if i >= n || src[i] != '<' {
return jsxNode{}, 0, fmt.Errorf("parseJSX: expected '<' at %d", i)
}
i++ // consume '<'
// Fragment: <> ... </>
if i < n && src[i] == '>' {
i++
children, ci, err := parseChildren(src, i)
if err != nil {
return jsxNode{}, 0, err
}
i, err = consumeCloseTag(src, ci)
if err != nil {
return jsxNode{}, 0, err
}
return jsxNode{kind: jsxFragment, children: children}, i, nil
}
// Tag name.
start := i
for i < n && isTagChar(src[i]) {
i++
}
if i == start {
return jsxNode{}, 0, fmt.Errorf("parseJSX: empty tag name at %d", start)
}
node := jsxNode{tag: src[start:i]}
if isComponentTag(node.tag) {
node.kind = jsxComponent
} else {
node.kind = jsxElement
}
attrs, ai, selfClose, err := parseAttrs(src, i)
if err != nil {
return jsxNode{}, 0, err
}
node.attrs = attrs
i = ai
if selfClose {
return node, i, nil
}
children, ci, err := parseChildren(src, i)
if err != nil {
return jsxNode{}, 0, err
}
node.children = children
i, err = consumeCloseTag(src, ci)
if err != nil {
return jsxNode{}, 0, err
}
return node, i, nil
}
// parseAttrs parses attributes after the tag name until `>` or `/>`. It returns
// the attrs, the index past the terminator, and whether the tag self-closed.
func parseAttrs(src string, i int) ([]jsxAttr, int, bool, error) {
n := len(src)
var attrs []jsxAttr
for i < n {
for i < n && isSpace(src[i]) {
i++
}
if i >= n {
break
}
switch {
case src[i] == '>':
return attrs, i + 1, false, nil
case src[i] == '/' && i+1 < n && src[i+1] == '>':
return attrs, i + 2, true, nil
case src[i] == '{': // {...spread}
expr, ni := captureBraces(src, i)
e := strings.TrimSpace(expr)
e = strings.TrimSpace(strings.TrimPrefix(e, "..."))
attrs = append(attrs, jsxAttr{kind: attrSpread, expr: e})
i = ni
default:
ns := i
for i < n && isAttrNameChar(src[i]) {
i++
}
if i == ns {
return nil, 0, false, fmt.Errorf("parseAttrs: unexpected %q at %d", src[i], i)
}
name := src[ns:i]
for i < n && isSpace(src[i]) {
i++
}
if i < n && src[i] == '=' {
i++
for i < n && isSpace(src[i]) {
i++
}
if i >= n {
return nil, 0, false, fmt.Errorf("parseAttrs: attr value expected")
}
if src[i] == '{' {
expr, ni := captureBraces(src, i)
attrs = append(attrs, jsxAttr{kind: attrExpr, name: name, expr: strings.TrimSpace(expr)})
i = ni
} else if src[i] == '"' || src[i] == '\'' {
q := src[i]
i++
vs := i
for i < n && src[i] != q {
i++
}
attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, value: src[vs:i]})
if i < n {
i++ // closing quote
}
} else {
return nil, 0, false, fmt.Errorf("parseAttrs: bad attr value at %d", i)
}
} else {
attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, boolt: true})
}
}
}
return nil, 0, false, fmt.Errorf("parseAttrs: unterminated tag")
}
// parseChildren parses child nodes until the matching `</`. It returns the nodes
// and the index at the `<` of the closing tag. Adjacent character data becomes a
// single jsxText node (JSX whitespace normalization happens in codegen).
func parseChildren(src string, i int) ([]jsxNode, int, error) {
n := len(src)
var nodes []jsxNode
var text strings.Builder
flush := func() {
if text.Len() > 0 {
nodes = append(nodes, jsxNode{kind: jsxText, text: text.String()})
text.Reset()
}
}
for i < n {
c := src[i]
switch {
case c == '<' && i+1 < n && src[i+1] == '/':
flush()
return nodes, i, nil
case c == '<':
flush()
child, ni, err := parseJSX(src, i)
if err != nil {
return nil, 0, err
}
nodes = append(nodes, child)
i = ni
case c == '{':
flush()
expr, ni := captureBraces(src, i)
nodes = append(nodes, jsxNode{kind: jsxExpr, expr: strings.TrimSpace(expr)})
i = ni
default:
text.WriteByte(c)
i++
}
}
return nil, 0, fmt.Errorf("parseChildren: unterminated (missing close tag)")
}
// consumeCloseTag consumes `</name>` (or `</>`) starting at src[i] == '<'.
func consumeCloseTag(src string, i int) (int, error) {
n := len(src)
if i+1 >= n || src[i] != '<' || src[i+1] != '/' {
return 0, fmt.Errorf("consumeCloseTag: expected '</' at %d", i)
}
i += 2
for i < n && src[i] != '>' {
i++
}
if i >= n {
return 0, fmt.Errorf("consumeCloseTag: unterminated close tag")
}
return i + 1, nil // past '>'
}
// captureBraces returns the text between a `{` at src[i] and its matching `}`
// (exclusive), and the index just past that `}`. It tracks strings, template
// literals (with `${}` interpolation), comments, and regex literals so braces
// inside them don't miscount — the same lexer the segmenter uses.
func captureBraces(src string, i int) (inner string, next int) {
n := len(src)
start := i + 1
i++ // skip opening '{'
depth := 0
state := stNormal
var tmplStack []int
var prevSig byte
for ; i < n; i++ {
c := src[i]
switch state {
case stNormal:
switch c {
case '/':
if i+1 < n && src[i+1] == '/' {
state = stLineComment
i++
continue
}
if i+1 < n && src[i+1] == '*' {
state = stBlockComment
i++
continue
}
if regexAllowed(src, i, prevSig) {
state = stRegex
prevSig = c
continue
}
prevSig = c
case '\'':
state = stSingle
prevSig = c
case '"':
state = stDouble
prevSig = c
case '`':
state = stTemplate
prevSig = c
case '{', '(', '[':
depth++
prevSig = c
case '}':
if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] {
tmplStack = tmplStack[:len(tmplStack)-1]
depth--
state = stTemplate
} else if depth > 0 {
depth--
prevSig = c
} else {
return src[start:i], i + 1 // matching close of the outer '{'
}
case ')', ']':
if depth > 0 {
depth--
}
prevSig = c
case '<':
// A `<` in expression position (and followed by a tag/fragment
// start) opens nested JSX, not a less-than: skip the whole element
// via parseJSX so its `</tag>` slashes and `{}` don't desync the JS
// lexer. Otherwise it's the comparison operator.
if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') {
if _, ni, err := parseJSX(src, i); err == nil {
i = ni - 1 // loop's i++ lands just past the element
prevSig = '>'
continue
}
}
prevSig = c
default:
if !isSpace(c) {
prevSig = c
}
}
case stLineComment:
if c == '\n' {
state = stNormal
}
case stBlockComment:
if c == '*' && i+1 < n && src[i+1] == '/' {
state = stNormal
i++
}
case stSingle:
if c == '\\' {
i++
} else if c == '\'' {
state = stNormal
prevSig = c
}
case stDouble:
if c == '\\' {
i++
} else if c == '"' {
state = stNormal
prevSig = c
}
case stTemplate:
if c == '\\' {
i++
} else if c == '`' {
state = stNormal
prevSig = c
} else if c == '$' && i+1 < n && src[i+1] == '{' {
depth++
tmplStack = append(tmplStack, depth)
state = stNormal
i++
}
case stRegex:
if c == '\\' {
i++
} else if c == '[' {
for i++; i < n; i++ {
if src[i] == '\\' {
i++
continue
}
if src[i] == ']' {
break
}
}
} else if c == '/' {
state = stNormal
prevSig = c
}
}
}
return src[start:], n // unterminated
}
func isComponentTag(tag string) bool {
if tag == "" {
return false
}
if strings.ContainsAny(tag, ".") {
return true // member expression component, e.g. <Foo.Bar>
}
c := tag[0]
return c >= 'A' && c <= 'Z'
}
func isTagChar(b byte) bool {
return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == '.' || b == ':' || b == '_'
}
func isAttrNameChar(b byte) bool {
return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == ':' || b == '_'
}
func isSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,257 @@
package bundler
import (
"os"
"path/filepath"
"strings"
"testing"
)
// renderComponent bundles a compiled component module (which must `export const A`)
// with a render harness, runs it in the goja SSR engine, and returns the HTML.
func renderComponent(t *testing.T, compiledJS string) (string, error) {
t.Helper()
entry := compiledJS + "\n" +
`import { render as _$r, createComponent as _$cc } from "solid-js/web";
globalThis.__render = function () {
var root = document.createElement("div");
var dispose = _$r(function () { return _$cc(A, {}); }, root);
var out = globalThis.__serialize(root);
dispose();
return out;
};`
bundle, err := BundleEntry(entry, ".")
if err != nil {
return "", err
}
eng, err := New()
if err != nil {
return "", err
}
if err := eng.LoadBundle(bundle); err != nil {
return "", err
}
return eng.Render()
}
// assertRenderEquivalent compiles src with babel AND the Go compiler, renders
// both, and requires identical HTML. This is the compiler's correctness oracle.
func assertRenderEquivalent(t *testing.T, name, src string) {
t.Helper()
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
babelJS, err := Compile(src, name+".tsx")
if err != nil {
t.Fatalf("[%s] babel compile: %v", name, err)
}
goJS, err := compileSolidGo(src, name+".tsx", false)
if err != nil {
t.Fatalf("[%s] go compile: %v\n--- go output ---\n%s", name, err, goJS)
}
babelHTML, err := renderComponent(t, babelJS)
if err != nil {
t.Fatalf("[%s] render babel: %v", name, err)
}
goHTML, err := renderComponent(t, goJS)
if err != nil {
t.Fatalf("[%s] render go: %v\n--- go output ---\n%s", name, err, goJS)
}
if babelHTML != goHTML {
t.Errorf("[%s] render mismatch:\n babel: %q\n go: %q\n--- go compiled ---\n%s", name, babelHTML, goHTML, goJS)
}
}
func TestGoCompilerRenderCore(t *testing.T) {
cases := []struct{ name, src string }{
{"static", `export const A = () => <div class="x">hi</div>;`},
{"dyn-text", `export const A = () => { const c = () => 42; return <div>{c()}</div>; };`},
{"nested", `export const A = () => { const x = () => "X"; return <div><span>a</span><b>{x()}</b></div>; };`},
{"mixed-children", `export const A = () => { const x = () => "X"; const y = () => "Y"; return <div>before {x()} after {y()}</div>; };`},
{"dyn-attr", `export const A = () => { const id = () => "foo"; return <div class="s" id={id()}>hi</div>; };`},
{"list", `export const A = () => { const items = ["a", "b", "c"]; return <ul>{items.map((i) => <li>{i}</li>)}</ul>; };`},
{"deep-static", `export const A = () => <section><header><h1>Title</h1></header><p>body text</p></section>;`},
{"multi-attr", `export const A = () => <input type="text" name="q" disabled />;`},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
assertRenderEquivalent(t, c.name, c.src)
})
}
}
func TestGoCompilerRenderComponents(t *testing.T) {
cases := []struct{ name, src string }{
{"component-children", `export const A = () => { const Box = (props) => <div class="box">{props.children}</div>; return <Box>hi</Box>; };`},
{"component-dyn-prop", `export const A = () => { const Lbl = (props) => <span>{props.text}</span>; const t = () => "yo"; return <Lbl text={t()} />; };`},
{"nested-components", `export const A = () => { const Row = (props) => <li>{props.children}</li>; return <ul><Row>one</Row><Row>two</Row></ul>; };`},
{"show-true", `import { Show } from "solid-js"; export const A = () => <div><Show when={true} fallback={<p>no</p>}>yes</Show></div>;`},
{"show-false", `import { Show } from "solid-js"; export const A = () => <div><Show when={false} fallback={<p>no</p>}>yes</Show></div>;`},
{"for", `import { For } from "solid-js"; export const A = () => <ul><For each={[1, 2, 3]}>{(n) => <li>{n}</li>}</For></ul>;`},
{"fragment", `export const A = () => { const a = () => "A"; const b = () => "B"; return <div>{a()}{b()}</div>; };`},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) { assertRenderEquivalent(t, c.name, c.src) })
}
}
func TestGoCompilerRenderSpreadRef(t *testing.T) {
cases := []struct{ name, src string }{
{"spread", `export const A = () => { const p = { id: "pid", title: "t" }; return <div {...p} class="x">hi</div>; };`},
{"spread-override", `export const A = () => { const p = { class: "from-p" }; return <div {...p} class="from-attr">hi</div>; };`},
{"ref", `export const A = () => { let r; return <div ref={r}>hi</div>; };`},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) { assertRenderEquivalent(t, c.name, c.src) })
}
}
// Compile every real .tsx with the Go compiler and assert it produces parseable
// output. This surfaces constructs the codebase uses that the codegen doesn't
// handle yet (the failures ARE the milestone-3/4 gap list).
func TestGoCompilerCompilesRealFiles(t *testing.T) {
files := walkRepoTSX(t)
var failed, ok int
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
continue
}
out, err := compileSolidGo(string(data), f, false)
if err != nil {
failed++
t.Logf("COMPILE-ERR %s: %v", f, err)
continue
}
if err := validateJS(out); err != nil {
failed++
t.Logf("PARSE-ERR %s: %v", f, err)
continue
}
ok++
}
t.Logf("Go compiler: %d/%d real .tsx produced parseable output (%d failed)", ok, len(files), failed)
}
// M5: dev mode wraps components with solid-refresh. Verify the shape and that
// every real file still produces parseable output with instrumentation on.
func TestGoCompilerRefreshInstrumentation(t *testing.T) {
src := `export function Counter() { const c = () => 1; return <div>{c()}</div>; }
export const Banner = () => <span>hi</span>;
const NOT_A_COMPONENT = 42;
function helper() { return 5; }`
out, err := compileSolidGo(src, "Refresh.tsx", true)
if err != nil {
t.Fatalf("dev compile: %v", err)
}
if err := validateJS(out); err != nil {
t.Fatalf("dev output does not parse: %v\n%s", err, out)
}
for _, want := range []string{
`from "solid-refresh"`,
"const _REGISTRY = _$$registry();",
`_$$component(_REGISTRY, "Counter", function Counter`,
`_$$component(_REGISTRY, "Banner",`,
`if (import.meta.hot) { _$$refresh("esm", import.meta.hot, _REGISTRY); }`,
} {
if !strings.Contains(out, want) {
t.Errorf("dev output missing %q\n--- output ---\n%s", want, out)
}
}
// Non-components must NOT be wrapped.
if strings.Contains(out, `"NOT_A_COMPONENT"`) || strings.Contains(out, `"helper"`) {
t.Errorf("non-component was wrapped:\n%s", out)
}
// Prod mode must have no refresh instrumentation.
prod, _ := compileSolidGo(src, "Refresh.tsx", false)
if strings.Contains(prod, "solid-refresh") {
t.Errorf("prod output leaked refresh instrumentation:\n%s", prod)
}
}
func TestGoCompilerDevCompilesRealFiles(t *testing.T) {
files := walkRepoTSX(t)
var failed, ok int
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
continue
}
out, err := compileSolidGo(string(data), f, true)
if err != nil {
failed++
t.Logf("DEV-COMPILE-ERR %s: %v", f, err)
continue
}
if err := validateJS(out); err != nil {
failed++
t.Logf("DEV-PARSE-ERR %s: %v", f, err)
continue
}
ok++
}
t.Logf("Go compiler (dev): %d/%d real .tsx parseable with refresh (%d failed)", ok, len(files), failed)
}
// Regression: a context provider's JSX children must evaluate lazily (inside the
// provider), or a consumer reads the context before it's set. Eager `children:`
// makes Consumer throw "no ctx"; lazy `get children()` renders correctly.
func TestGoCompilerContextChildren(t *testing.T) {
src := `import { createContext, useContext } from "solid-js";
const Ctx = createContext();
function Provider(props) { return <Ctx.Provider value="ok">{props.children}</Ctx.Provider>; }
function Consumer() { const v = useContext(Ctx); if (!v) throw new Error("no ctx"); return <span>{v}</span>; }
export const A = () => <Provider><Consumer /></Provider>;`
root, _ := filepath.Abs("../..")
t.Chdir(root)
goJS, err := compileSolidGo(src, "Ctx.tsx", false)
if err != nil {
t.Fatalf("compile: %v", err)
}
html, err := renderComponent(t, goJS)
if err != nil {
t.Fatalf("render (context leaked?): %v\n--- compiled ---\n%s", err, goJS)
}
if !strings.Contains(html, "ok") {
t.Errorf("expected context value in output, got: %q", html)
}
}
// Regression: object `style` must go through _$style (setProperty per key), not
// setAttribute (which stringifies to "[object Object]" and breaks positioning);
// innerHTML must be a property assignment (setAttribute is a no-op → empty icons).
func TestGoCompilerStyleAndInnerHTML(t *testing.T) {
root, _ := filepath.Abs("../..")
t.Chdir(root)
cases := []struct{ name, src, want, absent string }{
{"object-style", `export const A = () => { const s = () => ({ top: "10px", left: "20px" }); return <div style={s()}>x</div>; };`, "10px", "[object Object]"},
{"innerHTML", "export const A = () => { const h = () => '<path d=\"M1 2\"/>'; return <svg innerHTML={h()}></svg>; };", "<path", "innerHTML="},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
out, err := compileSolidGo(c.src, c.name+".tsx", false)
if err != nil {
t.Fatal(err)
}
html, err := renderComponent(t, out)
if err != nil {
t.Fatalf("render: %v\n%s", err, out)
}
if !strings.Contains(html, c.want) {
t.Errorf("output %q missing %q\ncompiled:\n%s", html, c.want, out)
}
if strings.Contains(html, c.absent) {
t.Errorf("output %q still contains broken %q", html, c.absent)
}
})
}
}

View File

@@ -0,0 +1,116 @@
package bundler
import "testing"
// parseOne parses a single JSX element starting at the first '<'.
func parseOne(t *testing.T, src string) jsxNode {
t.Helper()
i := 0
for i < len(src) && src[i] != '<' {
i++
}
node, next, err := parseJSX(src, i)
if err != nil {
t.Fatalf("parseJSX(%q): %v", src, err)
}
if next != len(src) {
t.Fatalf("parseJSX(%q): consumed to %d, want %d (trailing %q)", src, next, len(src), src[next:])
}
return node
}
func TestParseStaticElement(t *testing.T) {
n := parseOne(t, `<div class="x">hi</div>`)
if n.kind != jsxElement || n.tag != "div" {
t.Fatalf("kind/tag = %d/%q", n.kind, n.tag)
}
if len(n.attrs) != 1 || n.attrs[0].kind != attrStatic || n.attrs[0].name != "class" || n.attrs[0].value != "x" {
t.Fatalf("attrs = %+v", n.attrs)
}
if len(n.children) != 1 || n.children[0].kind != jsxText || n.children[0].text != "hi" {
t.Fatalf("children = %+v", n.children)
}
}
func TestParseComponent(t *testing.T) {
n := parseOne(t, `<Foo bar={1}>child</Foo>`)
if n.kind != jsxComponent || n.tag != "Foo" {
t.Fatalf("kind/tag = %d/%q", n.kind, n.tag)
}
if len(n.attrs) != 1 || n.attrs[0].kind != attrExpr || n.attrs[0].name != "bar" || n.attrs[0].expr != "1" {
t.Fatalf("attrs = %+v", n.attrs)
}
}
func TestParseNested(t *testing.T) {
n := parseOne(t, `<div><span>a</span><b>{x()}</b></div>`)
if len(n.children) != 2 {
t.Fatalf("want 2 element children, got %d: %+v", len(n.children), n.children)
}
if n.children[0].tag != "span" || n.children[1].tag != "b" {
t.Fatalf("child tags = %q, %q", n.children[0].tag, n.children[1].tag)
}
b := n.children[1]
if len(b.children) != 1 || b.children[0].kind != jsxExpr || b.children[0].expr != "x()" {
t.Fatalf("b children = %+v", b.children)
}
}
func TestParseFragment(t *testing.T) {
n := parseOne(t, `<>{a()}{b()}</>`)
if n.kind != jsxFragment || len(n.children) != 2 {
t.Fatalf("fragment: kind=%d children=%d", n.kind, len(n.children))
}
if n.children[0].expr != "a()" || n.children[1].expr != "b()" {
t.Fatalf("fragment children = %+v", n.children)
}
}
func TestParseSelfCloseAndBool(t *testing.T) {
n := parseOne(t, `<input disabled type="text" />`)
if n.tag != "input" || len(n.children) != 0 {
t.Fatalf("input: tag=%q children=%d", n.tag, len(n.children))
}
if len(n.attrs) != 2 || !n.attrs[0].boolt || n.attrs[0].name != "disabled" {
t.Fatalf("attrs = %+v", n.attrs)
}
if n.attrs[1].name != "type" || n.attrs[1].value != "text" {
t.Fatalf("type attr = %+v", n.attrs[1])
}
}
func TestParseSpread(t *testing.T) {
n := parseOne(t, `<div {...p} class="x">hi</div>`)
if len(n.attrs) != 2 || n.attrs[0].kind != attrSpread || n.attrs[0].expr != "p" {
t.Fatalf("attrs = %+v", n.attrs)
}
}
// The hardest case: an expression child containing nested JSX AND braces inside
// strings/templates. The parser must capture the whole expression opaquely.
func TestParseExprWithNestedJSX(t *testing.T) {
n := parseOne(t, "<ul>{items.map((x) => <li>{x}</li>)}</ul>")
if len(n.children) != 1 || n.children[0].kind != jsxExpr {
t.Fatalf("children = %+v", n.children)
}
if n.children[0].expr != "items.map((x) => <li>{x}</li>)" {
t.Fatalf("expr = %q", n.children[0].expr)
}
}
func TestCaptureBracesLexer(t *testing.T) {
cases := []struct{ in, want string }{
{`{a + "}" + b}`, `a + "}" + b`},
{"{`a${nested}b`}", "`a${nested}b`"},
{`{ {k: 1} }`, ` {k: 1} `},
{`{f(/[}]/)}`, `f(/[}]/)`},
{`{x} rest`, `x`},
}
for _, c := range cases {
got, next := captureBraces(c.in, 0)
if got != c.want {
t.Errorf("captureBraces(%q) = %q, want %q", c.in, got, c.want)
}
_ = next
}
}

91
go/bundler/css.go Normal file
View File

@@ -0,0 +1,91 @@
package bundler
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
// goja), feeding it the utility-class candidates scanned from source files, then
// minifies via tdewolff/minify. style.css is the single entry/config for both
// bundles — they differ only in which source files are scanned for candidates.
import (
"fmt"
"os"
"path/filepath"
"github.com/tdewolff/minify/v2"
mincss "github.com/tdewolff/minify/v2/css"
)
// Tailwind source patterns - configured here rather than in CSS so each bundle
// scans only the files it actually needs. The scanner filters by the suffix of
// each pattern, so separate entries are needed for .js and .ts source files.
var twSourcesSPA = []string{
"../src/**/*.js",
"../src/**/*.ts",
"../src/**/*.jsx",
"../src/**/*.tsx",
"../../internal/handlers/templates/**/*.html",
}
var twSourcesPublic = []string{
"../../internal/handlers/templates/**/*.html",
// Public pages authored as Solid components in .tsx (SSR'd via goja).
"../src/pages/public/**/*.tsx",
"../src/pages/public/**/*.jsx",
"../src/pages/public/**/*.ts",
"../src/pages/public/**/*.js",
// Shared UI component library. If a public page renders any ui/ component
// (data tables, tabs, icons, etc.), its classes must be in this bundle too,
// so scan the whole library rather than just the env badge / layout.
"../src/ui/**/*.ts",
"../src/ui/**/*.js",
}
// styleEntry is the Tailwind entry/config, relative to frontendDir. Both the SPA
// and public bundles compile it; include.css (a former one-line passthrough) is gone.
const styleEntry = "css/style.css"
var m *minify.M
func init() {
m = minify.New()
m.AddFunc("text/css", mincss.Minify)
}
func bundleSPACSS() (bundleStats, error) {
return compileCSSBundle("SPA", twSourcesSPA, "bundle.min.css")
}
func bundlePublicCSS() (bundleStats, error) {
return compileCSSBundle("public", twSourcesPublic, "public.bundle.min.css")
}
// compileCSSBundle scans twSources for candidates, compiles style.css with the
// official Tailwind compiler, minifies, and writes outName to wwwroot. It prints
// a timing line for the Tailwind step so the compile can be profiled.
func compileCSSBundle(label string, twSources []string, outName string) (bundleStats, error) {
entryPath := filepath.Join(frontendDir, styleEntry)
src, err := os.ReadFile(entryPath)
if err != nil {
return bundleStats{}, fmt.Errorf("reading %s: %w", styleEntry, err)
}
cssDir := filepath.Dir(entryPath)
candidates := scanSources(cssDir, twSources)
compiled, count, err := twCompile(string(src), cssDir, candidates)
if err != nil {
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
}
fmt.Printf(" Tailwind (%s): %d candidates, %d utilities compiled\n",
label, len(candidates), count)
minified, err := m.String("text/css", compiled)
if err != nil {
return bundleStats{}, fmt.Errorf("minifying %s CSS: %w", label, err)
}
outPath := filepath.Join(outputDir, outName)
if err := os.WriteFile(outPath, []byte(minified), 0644); err != nil {
return bundleStats{}, err
}
return bundleStats{files: len(candidates), bytes: len(minified)}, nil
}

89
go/bundler/export_shim.go Normal file
View File

@@ -0,0 +1,89 @@
package bundler
// defaultExportShimPlugin bridges two ESM-strictness mismatches that
// existed in the previous custom bundler:
//
// 1. `export default function Name` also emits `export { Name }` so
// callers using `import { Name } from "..."` resolve correctly.
// 2. If the source declares `export function/class/const Name` (or
// `Name`) where Name matches the file's basename, synthesize
// `export { Name as default }` so callers using `import Name from
// "..."` resolve correctly. The old bundler made every IIFE
// return value available as both the named symbol AND the default
// import; standard ESM does not, so esbuild needs the shim until
// callers migrate to named imports throughout.
import (
"os"
"path/filepath"
"regexp"
"strings"
esbuild "github.com/evanw/esbuild/pkg/api"
)
func defaultExportShimPlugin() esbuild.Plugin {
return esbuild.Plugin{
Name: "default-export-shim",
Setup: func(b esbuild.PluginBuild) {
b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(js|ts)$`}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
raw, err := os.ReadFile(args.Path)
if err != nil {
return esbuild.OnLoadResult{}, err
}
out := addNamedExportForDefault(string(raw))
out = addDefaultExportForFilenameMatch(out, args.Path)
loader := esbuild.LoaderJS
if strings.HasSuffix(args.Path, ".ts") {
loader = esbuild.LoaderTS
}
return esbuild.OnLoadResult{Contents: &out, Loader: loader}, nil
})
},
}
}
var reFilenameMatchExport = regexp.MustCompile(`(?m)^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+(\w+)`)
var reExistingDefault = regexp.MustCompile(`(?m)^export\s+default\b`)
// addDefaultExportForFilenameMatch synthesizes `export { Name as
// default }` if the source declares a top-level export whose name
// matches the file's basename and the file does not already have a
// default export. This preserves the old bundler's behavior where
// `import Foo from "./Foo.js"` resolved against `export function
// Foo(...)`.
func addDefaultExportForFilenameMatch(src, path string) string {
if reExistingDefault.MatchString(src) {
return src
}
base := filepath.Base(path)
base = strings.TrimSuffix(base, filepath.Ext(base))
if base == "" {
return src
}
for _, m := range reFilenameMatchExport.FindAllStringSubmatch(src, -1) {
if m[1] == base {
return src + "\nexport { " + base + " as default };\n"
}
}
return src
}
var reExportDefaultDecl = regexp.MustCompile(`(?m)^export\s+default\s+(?:async\s+)?(?:function|class)\s+(\w+)`)
func addNamedExportForDefault(src string) string {
matches := reExportDefaultDecl.FindAllStringSubmatch(src, -1)
if len(matches) == 0 {
return src
}
names := make([]string, 0, len(matches))
seen := map[string]bool{}
for _, m := range matches {
if seen[m[1]] {
continue
}
seen[m[1]] = true
names = append(names, m[1])
}
return src + "\nexport { " + strings.Join(names, ", ") + " };\n"
}

201
go/bundler/faicons.go Normal file
View File

@@ -0,0 +1,201 @@
package bundler
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and
// looking icons up by runtime string, we scan the app for the icon names it
// actually references and emit a registry of just those icons' SVG data, pulled
// from the FontAwesome SVGs under frontend/icons/. Icons.tsx looks up that
// registry exactly like it used to call FontAwesome.findIconDefinition.
// FA prefix -> frontend/icons/<dir>. cdrateline renders classic far/fas; a Sharp
// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid".
var faStyleDirs = map[string]string{
"far": "regular",
"fas": "solid",
}
// faIconsDir holds the FontAwesome SVGs (the kit's svgs-full/, relocated here),
// grouped by style. Only the styles in faStyleDirs are read; the rest are unused.
const faIconsDir = "frontend/icons"
var faOutFile = filepath.Join(frontendDir, "src", "ui", "generated", "faIcons.ts")
var (
// icon="name" / icon: "name"
reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`)
// icon={ ... } — dynamic expressions; pull any string literals (ternaries etc.)
reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\{([^}]*)\}`)
reStrLit = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`)
reViewBox = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`)
rePathD = regexp.MustCompile(`<path[^>]*\bd="([^"]+)"`)
// registerIcon("name", …) — custom (non-FontAwesome) icons defined in-app.
reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`)
)
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
// no-op when the kit isn't present (CI builds use the committed registry).
func generateFAIcons() error {
if _, err := os.Stat(faIconsDir); err != nil {
return nil // SVGs absent — keep the committed registry
}
names, custom, err := scanIconNames(filepath.Join(frontendDir, "src"))
if err != nil {
return fmt.Errorf("scanning icon names: %w", err)
}
type entry struct {
key, x, y, w, h, path string
}
var entries []entry
var missing []string
for _, name := range names {
found := false
for prefix, dir := range faStyleDirs {
svg, err := os.ReadFile(filepath.Join(faIconsDir, dir, name+".svg"))
if err != nil {
continue
}
x, y, w, h, d, ok := parseFASvg(string(svg))
if !ok {
continue
}
entries = append(entries, entry{prefix + ":" + name, x, y, w, h, d})
found = true
}
if !found {
missing = append(missing, name)
}
}
sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key })
var b strings.Builder
b.WriteString("// AUTO-GENERATED by cmd/bundle (generateFAIcons) — do not edit.\n")
b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n")
b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n")
b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n")
b.WriteString("// frontend/icons/ is present; committed so CI needs no SVGs.\n")
b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n")
for _, e := range entries {
b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path))
}
b.WriteString("};\n")
if err := os.MkdirAll(filepath.Dir(faOutFile), 0755); err != nil {
return err
}
if err := os.WriteFile(faOutFile, []byte(b.String()), 0644); err != nil {
return err
}
// A referenced name that's not in the FA kit is either a registered custom icon
// (expected) or an unknown name — almost always a typo (report separately).
var customUsed, unknown []string
for _, name := range missing {
if custom[name] {
customUsed = append(customUsed, name)
} else {
unknown = append(unknown, name)
}
}
fmt.Printf(" FA icons: %d defs for %d names", len(entries), len(names))
if len(customUsed) > 0 {
sort.Strings(customUsed)
fmt.Printf(" (%d custom: %s)", len(customUsed), strings.Join(customUsed, ", "))
}
if len(unknown) > 0 {
sort.Strings(unknown)
fmt.Printf(" (%d unknown, likely typos: %s)", len(unknown), strings.Join(unknown, ", "))
}
fmt.Println()
return nil
}
// scanIconNames walks dir once and returns two things: the sorted list of icon
// names referenced anywhere (static `icon="x"`/`icon: "x"` plus string literals
// inside `icon={...}` expressions), and the set of custom icon names registered
// via registerIcon("name", …). The latter lets the caller tell a legitimate
// custom icon apart from a typo when a referenced name isn't in the FA kit.
func scanIconNames(dir string) (names []string, custom map[string]bool, err error) {
set := map[string]bool{}
custom = map[string]bool{}
err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
switch filepath.Ext(path) {
case ".ts", ".tsx", ".js", ".jsx":
default:
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
s := string(data)
for _, m := range reIconAttr.FindAllStringSubmatch(s, -1) {
set[m[1]] = true
}
for _, bm := range reIconBrace.FindAllStringSubmatch(s, -1) {
for _, sm := range reStrLit.FindAllStringSubmatch(bm[1], -1) {
set[sm[1]] = true
}
}
for _, m := range reRegisterIcon.FindAllStringSubmatch(s, -1) {
custom[m[1]] = true
}
return nil
})
if err != nil {
return nil, nil, err
}
names = make([]string, 0, len(set))
for n := range set {
names = append(names, n)
}
sort.Strings(names)
return names, custom, nil
}
// parseFASvg pulls the concatenated path data (solid/regular icons are
// single-path; joining is safe) and a viewBox out of a FontAwesome kit SVG.
//
// The kit's "full" SVGs keep FA's 512-unit icon design centred inside a 640x640
// canvas — a uniform 10% margin (square-full/circle/bars all span 64..576). We
// inset the viewBox to that 512 design box so icons render at their intended
// (FA6-equivalent) size instead of ~20% small on the padded canvas. The inset is
// UNIFORM across every icon, so proportions are preserved — a caret stays a small
// glyph. (A per-glyph bounding-box crop was wrong: it can't tell an icon meant to
// fill its box from one deliberately padded, so it blew small glyphs up to fill it.)
func parseFASvg(svg string) (x, y, w, h, path string, ok bool) {
var ds []string
for _, m := range rePathD.FindAllStringSubmatch(svg, -1) {
ds = append(ds, m[1])
}
if len(ds) == 0 {
return "", "", "", "", "", false
}
vb := reViewBox.FindStringSubmatch(svg)
if vb == nil {
return "", "", "", "", "", false
}
vw, e1 := strconv.ParseFloat(vb[1], 64)
vh, e2 := strconv.ParseFloat(vb[2], 64)
if e1 != nil || e2 != nil {
return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is
}
mx, my := vw/10, vh/10
return numStr(mx), numStr(my), numStr(vw-2*mx), numStr(vh-2*my), strings.Join(ds, " "), true
}
func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }

138
go/bundler/genroutes.go Normal file
View File

@@ -0,0 +1,138 @@
package bundler
// Public-route code generation. The single source of truth is the TypeScript
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +
// goja, so it's real evaluation, not fragile text parsing) and generates:
//
// - internal/handlers/public_pages.gen.go Go registry: routes + <title> +
// the page's pre-rendered body (baked here, see genssr.go: writeGoRegistry)
// - frontend/src/pages/public/routes.gen.ts client router maps: route → body
// component (publicRoutes) and route → <title> (publicTitles)
//
// Generated files are committed like the other build artifacts; regenerate by
// running the bundler.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/dop251/goja"
esbuild "github.com/evanw/esbuild/pkg/api"
)
const pagesManifest = "src/pages/public/pages.ts" // relative to frontendDir
type pageDef struct {
Path string `json:"path"`
Module string `json:"module"` // relative to frontend/src
Component string `json:"component"`
Title string `json:"title"`
Dynamic bool `json:"dynamic"` // ISR page: also bake its render JS for request-time data rendering
}
func generatePublicRoutes() error {
defs, err := loadPageDefs()
if err != nil {
return fmt.Errorf("loading page manifest: %w", err)
}
if err := writeGoRegistry(defs); err != nil {
return fmt.Errorf("writing Go registry: %w", err)
}
if err := writeClientRoutes(defs); err != nil {
return fmt.Errorf("writing client routes: %w", err)
}
fmt.Printf(" Public routes: %d page(s) generated\n", len(defs))
return nil
}
// loadPageDefs bundles and evaluates the TS manifest to get the page list.
func loadPageDefs() ([]pageDef, error) {
absCwd, err := filepath.Abs(".")
if err != nil {
return nil, err
}
pagesAbs := filepath.ToSlash(filepath.Join(absCwd, frontendDir, pagesManifest))
entry := fmt.Sprintf("import { publicPages } from %q;\nglobalThis.__PAGES__ = JSON.stringify(publicPages);", pagesAbs)
res := esbuild.Build(esbuild.BuildOptions{
Stdin: &esbuild.StdinOptions{
Contents: entry,
ResolveDir: absCwd,
Sourcefile: "pages-manifest.js",
Loader: esbuild.LoaderTS,
},
Bundle: true,
Format: esbuild.FormatIIFE,
Target: esbuild.ES2017,
Platform: esbuild.PlatformNeutral,
LogLevel: esbuild.LogLevelSilent,
Write: false,
})
if len(res.Errors) > 0 {
msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{})
return nil, fmt.Errorf("esbuild: %s", strings.Join(msgs, "\n"))
}
vm := goja.New()
if _, err := vm.RunString(string(res.OutputFiles[0].Contents)); err != nil {
return nil, err
}
raw := vm.Get("__PAGES__")
if raw == nil {
return nil, fmt.Errorf("manifest did not export publicPages")
}
var defs []pageDef
if err := json.Unmarshal([]byte(raw.String()), &defs); err != nil {
return nil, err
}
return defs, nil
}
// goRoute maps a URL pathname to a Go ServeMux pattern. Root needs "/{$}" so
// it matches exactly instead of as a catch-all subtree.
func goRoute(pathname string) string {
if pathname == "/" {
return "/{$}"
}
return pathname
}
func writeClientRoutes(defs []pageDef) error {
// Dedupe imports by component name (a component may back several routes).
seen := map[string]bool{}
var imports, entries, titles strings.Builder
for _, d := range defs {
if !seen[d.Component] {
seen[d.Component] = true
rel, err := filepath.Rel("pages/public", filepath.FromSlash(d.Module))
if err != nil {
return err
}
imp := "./" + filepath.ToSlash(rel)
fmt.Fprintf(&imports, "import { %s } from %q;\n", d.Component, imp)
}
fmt.Fprintf(&entries, " %q: %s,\n", d.Path, d.Component)
fmt.Fprintf(&titles, " %q: %q,\n", d.Path, d.Title)
}
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\n")
b.WriteString("import { JSXElement } from \"solid-js\";\n")
b.WriteString(imports.String())
b.WriteString("\n// Body component for each public route, keyed by URL pathname. The client\n")
b.WriteString("// router (public.ts) renders these when navigating without a full reload.\n")
b.WriteString("export const publicRoutes: Record<string, () => JSXElement> = {\n")
b.WriteString(entries.String())
b.WriteString("};\n")
b.WriteString("\n// <title> for each public route, applied by the client router on navigation\n")
b.WriteString("// (the first load gets its title from the server-rendered shell).\n")
b.WriteString("export const publicTitles: Record<string, string> = {\n")
b.WriteString(titles.String())
b.WriteString("};\n")
return os.WriteFile(filepath.Join(frontendDir, "src", "pages", "public", "routes.gen.ts"), []byte(b.String()), 0644)
}

154
go/bundler/genssr.go Normal file
View File

@@ -0,0 +1,154 @@
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)
}

View File

@@ -0,0 +1,201 @@
//go:build dev
package bundler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// chromePath returns a headless-capable Chrome binary, or "" if none is found.
func chromePath() string {
candidates := []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
}
for _, name := range []string{"google-chrome", "chromium", "chromium-browser", "google-chrome-stable"} {
if p, err := exec.LookPath(name); err == nil {
candidates = append(candidates, p)
}
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
// TestBrowserNativeESMRenders proves the whole native-ESM path works in a real
// browser: the import map resolves solid-js to one vendored instance, the module
// server transforms + serves the entry and a .tsx component (solid-refresh
// compiled), and Solid mounts it reactively. The page reports its rendered text
// back to the test via a beacon (robust against headless Chrome's one-shot exit
// quirks). It does NOT assert the interactive hot-swap — that needs a persistent
// CDP session and is the final manual check — but removes the largest
// browser-integration risk.
func TestBrowserNativeESMRenders(t *testing.T) {
chrome := chromePath()
if chrome == "" {
t.Skip("no headless Chrome/Chromium available")
}
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("browser smoke test only wired for macOS/Linux")
}
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
frontendAbs, _ := filepath.Abs(frontendDir)
// Fixtures in a temp src root so the real source tree (and Tailwind scan /
// route gen) is untouched. The component uses JSX (→ solid-refresh) and a
// signal (→ real reactivity); the entry imports it via a `.js` specifier
// (→ .tsx resolution), then beacons back the rendered text.
tmpSrc := evalSymlinks(t.TempDir())
writeFile(t, filepath.Join(tmpSrc, "Widget.tsx"),
`import { createSignal } from "solid-js";
export default function Widget() {
const [msg] = createSignal("hello-hmr-rendered");
return <div id="w">{msg()}</div>;
}
`)
writeFile(t, filepath.Join(tmpSrc, "entry.tsx"),
`import { render } from "solid-js/web";
import Widget from "./Widget.js";
render(() => <Widget/>, document.getElementById("app"));
setTimeout(() => {
const el = document.getElementById("w");
fetch("/__result?text=" + encodeURIComponent(el ? el.textContent : "EMPTY"));
}, 0);
`)
eps, err := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
if err != nil {
t.Fatalf("vendor manifest: %v", err)
}
d := &devServer{
hub: newHub(),
frontend: frontendAbs,
srcRoot: tmpSrc,
vendor: filepath.Join(frontendAbs, "vendor"),
graph: newModuleGraph(),
vendorCache: map[string][]byte{},
cssTrigger: make(chan struct{}, 1),
vendorEntrypoints: eps,
}
d.importMapJSON = d.buildImportMap()
rendered := make(chan string, 1)
var diagMu sync.Mutex
var diag []string
mux := http.NewServeMux()
d.register(mux)
mux.HandleFunc("/__result", func(w http.ResponseWriter, r *http.Request) {
select {
case rendered <- r.URL.Query().Get("text"):
default:
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/__diag", func(w http.ResponseWriter, r *http.Request) {
diagMu.Lock()
diag = append(diag, r.URL.Query().Get("text"))
diagMu.Unlock()
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html><html><head>
<script>
function beacon(p,t){fetch(p+'?text='+encodeURIComponent(t));}
window.addEventListener('error', function(e){beacon('/__diag','ERR:'+((e.error&&e.error.stack)||e.message));});
window.addEventListener('unhandledrejection', function(e){beacon('/__diag','REJ:'+((e.reason&&e.reason.stack)||String(e.reason)));});
window.addEventListener('DOMContentLoaded', function(){beacon('/__diag','DOMCONTENTLOADED');});
</script>
<script type="importmap">%s</script>
</head><body><div id="app"></div>
<script type="module" src="/@src/entry.tsx"></script>
</body></html>`, d.importMapJSON)
})
srv := httptest.NewServer(mux)
defer srv.Close()
// Sanity-check that every module the page needs serves (200) before the browser.
for _, m := range []string{"/@src/entry.tsx", "/@src/Widget.tsx", "/@hmr/client",
vendorURLPrefix + "solid-js.js", vendorURLPrefix + "solid-js/web.js"} {
resp, err := http.Get(srv.URL + m)
if err != nil {
t.Fatalf("GET %s: %v", m, err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("GET %s -> %d", m, resp.StatusCode)
}
}
// Launch headless Chrome to load the page; wait for the render beacon rather
// than for Chrome to exit (new headless doesn't reliably one-shot-exit here).
// Use a best-effort temp profile dir (not t.TempDir): Chrome's detached helper
// processes may still be writing to it at cleanup time, and t.TempDir's strict
// RemoveAll would then fail the test.
userDir, _ := os.MkdirTemp("", "hmr-chrome-*")
defer os.RemoveAll(userDir)
devNull, _ := os.Open(os.DevNull)
defer devNull.Close()
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, chrome,
"--headless=new", "--disable-gpu", "--no-sandbox", "--no-first-run",
"--user-data-dir="+userDir,
"--disable-background-networking", "--disable-component-update",
"--disable-default-apps", "--disable-sync", "--no-default-browser-check",
srv.URL,
)
cmd.Stdout = devNull
cmd.Stderr = devNull
if err := cmd.Start(); err != nil {
cancel()
t.Fatalf("start chrome: %v", err)
}
defer func() { cancel(); cmd.Wait() }()
select {
case text := <-rendered:
if text != "hello-hmr-rendered" {
t.Fatalf("browser rendered %q, want %q", text, "hello-hmr-rendered")
}
t.Log("native-ESM app rendered in headless Chrome (import map + single solid-js + solid-refresh component)")
case <-time.After(25 * time.Second):
diagMu.Lock()
msgs := strings.Join(diag, "\n ")
diagMu.Unlock()
t.Fatalf("timed out waiting for the browser render beacon.\nbrowser diagnostics:\n %s", msgs)
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

279
go/bundler/hmr_client.go Normal file
View File

@@ -0,0 +1,279 @@
//go:build dev
package bundler
import "net/http"
// hmrUpdate names one boundary module to re-import at a given version.
type hmrUpdate struct {
Path string `json:"path"`
Timestamp int64 `json:"timestamp"`
}
// hmrError describes a compile/transform failure surfaced to the browser as a
// full-screen error overlay (Vite-style). Message is the full formatted error
// text (esbuild carries a code frame; the Solid compiler a plain message).
type hmrError struct {
Message string `json:"message"`
File string `json:"file,omitempty"`
}
// hmrMessage is the WebSocket payload pushed to the browser.
type hmrMessage struct {
Type string `json:"type"` // "update" | "full-reload" | "css-update" | "error"
Updates []hmrUpdate `json:"updates,omitempty"`
Path string `json:"path,omitempty"` // css-update: the stylesheet path
Err *hmrError `json:"err,omitempty"` // error: the compile failure to display
}
// serveClient serves the HMR client runtime as an ES module.
func (d *devServer) serveClient(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write([]byte(hmrClientJS))
}
// hmrClientJS is the browser runtime: it owns the WebSocket, exposes
// createHotContext (the import.meta.hot the transformed modules bind), and
// applies updates. The accept/dispose/data protocol mirrors Vite's so
// solid-refresh's `esm` path (hot.data + hot.accept(cb) + hot.invalidate) works
// unchanged — a changed boundary is re-imported, and the PREVIOUS instance's
// accept callback runs with the new module namespace, patching the live registry.
//
// It also exports showErrorOverlay/clearErrorOverlay and renders a Vite-style
// full-screen compile-error overlay. A module that fails to transform is served
// as a tiny stub that imports showErrorOverlay and calls it (see errorModule in
// hmr_server.go), so the overlay pops the instant a broken module is imported —
// on first load or on a hot re-import. The overlay auto-clears once a hot-update
// batch completes without any module surfacing an error (see applyUpdates).
const hmrClientJS = `
// --- module-level HMR state, keyed by base module URL --------------------------
const hotModulesMap = new Map(); // id -> { id, callbacks: [{fn}] }
const dataMap = new Map(); // id -> persistent data object (survives reloads)
const disposeMap = new Map(); // id -> dispose callback
const declined = new Set(); // ids that opted out of HMR
export function createHotContext(id) {
if (!dataMap.has(id)) dataMap.set(id, {});
const existing = hotModulesMap.get(id);
// A fresh instance of this module is registering; clear its accept callbacks
// (applyUpdate has already snapshotted the previous instance's).
if (existing) existing.callbacks = [];
function pushAccept(fn) {
let mod = hotModulesMap.get(id);
if (!mod) { mod = { id, callbacks: [] }; hotModulesMap.set(id, mod); }
mod.callbacks.push({ fn });
}
return {
get data() { return dataMap.get(id); },
accept(deps, cb) {
// accept() | accept(fn) | accept(deps, fn) — self-accept in every form we use.
if (typeof deps === 'function' || deps == null) pushAccept(deps);
else pushAccept(cb);
},
dispose(cb) { disposeMap.set(id, cb); },
prune(cb) { disposeMap.set(id, cb); },
invalidate() { fullReload(); },
decline() { declined.add(id); },
on() {}, off() {}, send() {},
};
}
// --- recompile indicator -------------------------------------------------------
// A small, non-blocking badge so a recompile doesn't look like a frozen page:
// shown the moment an update arrives and hidden once the new module is imported
// and applied. The
// await below yields the event loop, so the spinner paints and animates while the
// server compiles.
let hmrBusy = 0;
let hmrEl = null;
function hmrIndicator() {
if (hmrEl || typeof document === 'undefined') return hmrEl;
const head = document.head || document.documentElement;
const style = document.createElement('style');
style.textContent = '@keyframes hmr-spin{to{transform:rotate(360deg)}}';
head.appendChild(style);
hmrEl = document.createElement('div');
hmrEl.setAttribute('style',
'position:fixed;bottom:14px;right:14px;z-index:2147483647;display:none;' +
'align-items:center;gap:8px;padding:7px 12px;border-radius:9px;' +
'font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb;' +
'background:rgba(17,24,39,.92);box-shadow:0 6px 18px rgba(0,0,0,.35);' +
'pointer-events:none;user-select:none');
hmrEl.innerHTML =
'<span style="width:11px;height:11px;border-radius:50%;display:inline-block;' +
'border:2px solid rgba(148,163,184,.4);border-top-color:#60a5fa;' +
'animation:hmr-spin .6s linear infinite"></span><span data-hmr-label></span>';
(document.body || document.documentElement).appendChild(hmrEl);
return hmrEl;
}
function hmrShow(label) {
const el = hmrIndicator();
if (!el) return;
const l = el.querySelector('[data-hmr-label]');
if (l) l.textContent = label || 'recompiling…';
el.style.display = 'flex';
}
function hmrBusyStart() { hmrBusy++; hmrShow('recompiling…'); }
function hmrBusyEnd() { hmrBusy = Math.max(0, hmrBusy - 1); if (hmrBusy === 0 && hmrEl) hmrEl.style.display = 'none'; }
// --- compile-error overlay -----------------------------------------------------
// A Vite-style full-screen overlay for compile/transform failures. errorEpoch is
// bumped every time an error surfaces; applyUpdates snapshots it around a hot
// batch and clears the overlay only if the batch introduced no new error, so a
// fixed file dismisses the overlay automatically. The overlay lives in a shadow
// root so the app's stylesheet (Tailwind reset et al.) can't restyle it.
let overlayEl = null;
let errorEpoch = 0;
const HMR_OVERLAY_ID = '__hmr-error-overlay';
function escapeHTML(s) {
return String(s).replace(/[&<>]/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;';
});
}
function overlayHTML(message, file) {
const css =
':host{all:initial}' +
'.backdrop{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.66);' +
'display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:6vh 20px;' +
'font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
'.panel{width:100%;max-width:min(1000px,92vw);margin:auto 0;background:#1b1b1f;color:#e6e6e6;' +
'border:1px solid #ff5555;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden}' +
'.head{display:flex;align-items:center;gap:10px;padding:12px 14px;background:#2a1416;' +
'border-bottom:1px solid rgba(255,85,85,.35)}' +
'.badge{color:#ff6b6b;font-weight:700;letter-spacing:.03em;text-transform:uppercase;font-size:12px}' +
'.file{color:#9aa0a6;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
'.close{margin-left:auto;background:transparent;border:0;color:#9aa0a6;cursor:pointer;' +
'font-size:16px;line-height:1;padding:4px 7px;border-radius:6px}' +
'.close:hover{color:#fff;background:rgba(255,255,255,.08)}' +
'.body{margin:0;padding:16px;white-space:pre-wrap;word-break:break-word;color:#ffb4b4;' +
'font-size:13px;max-height:62vh;overflow:auto}' +
'.hint{padding:10px 14px;border-top:1px solid rgba(255,255,255,.06);color:#7c828a;font-size:12px}';
return '<style>' + css + '</style>' +
'<div class="backdrop">' +
'<div class="panel">' +
'<div class="head">' +
'<span class="badge">Compile Error</span>' +
(file ? '<span class="file">' + escapeHTML(file) + '</span>' : '') +
'<button class="close" title="Dismiss (Esc)">✕</button>' +
'</div>' +
'<pre class="body">' + escapeHTML(message) + '</pre>' +
'<div class="hint">Fix the error and save — this overlay clears automatically.</div>' +
'</div>' +
'</div>';
}
export function showErrorOverlay(err) {
errorEpoch++;
if (typeof document === 'undefined') return;
const message = (err && (err.message || err.msg)) || String(err || 'Unknown error');
const file = (err && err.file) || '';
console.error('[hmr] compile error' + (file ? ' in ' + file : '') + '\n' + message);
clearErrorOverlay();
const host = document.createElement('div');
host.id = HMR_OVERLAY_ID;
const root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host;
root.innerHTML = overlayHTML(message, file);
const closeBtn = root.querySelector('.close');
if (closeBtn) closeBtn.addEventListener('click', clearErrorOverlay);
(document.body || document.documentElement).appendChild(host);
overlayEl = host;
}
export function clearErrorOverlay() {
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
overlayEl = null;
}
if (typeof document !== 'undefined') {
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlayEl) clearErrorOverlay();
});
}
async function applyUpdate(update) {
const id = update.path;
hmrBusyStart();
try {
if (declined.has(id)) return fullReload();
const mod = hotModulesMap.get(id);
if (!mod) return fullReload(); // module not tracked yet — reload to be safe
const callbacks = mod.callbacks; // the live instance's accept callbacks
const disposer = disposeMap.get(id);
if (disposer) { try { await disposer(dataMap.get(id)); } catch (e) { console.error(e); } }
let newMod;
try {
newMod = await import(id + (id.includes('?') ? '&' : '?') + 't=' + update.timestamp);
} catch (e) {
console.error('[hmr] failed to re-import', id, e);
return fullReload();
}
for (const cb of callbacks) {
if (cb.fn) { try { cb.fn(newMod); } catch (e) { console.error(e); } }
}
console.log('[hmr] updated', id);
} finally {
hmrBusyEnd();
}
}
function updateCSS(path) {
const links = document.querySelectorAll('link[rel="stylesheet"]');
for (const link of links) {
const url = new URL(link.href, location.href);
if (url.pathname === path) {
const next = link.cloneNode();
next.href = url.pathname + '?t=' + Date.now();
next.onload = () => link.remove();
link.after(next);
console.log('[hmr] css updated', path);
return;
}
}
// The stylesheet isn't linked on this page — the dev server broadcasts CSS
// updates for both the SPA (/bundle.min.css) and public (/public.bundle.min.css)
// bundles to every client, but each page carries only one. An update for the
// other bundle is simply not applicable here, so ignore it. (Forcing a full
// reload instead would defeat the .tsx component HMR that ran moments earlier.)
}
function fullReload() { hmrShow('reloading…'); location.reload(); }
// applyUpdates runs a hot-update batch, then clears the error overlay iff no
// module surfaced a compile error while importing (errorEpoch unchanged). Module
// evaluation is synchronous within an import(), so a broken module's
// showErrorOverlay() has already run by the time its applyUpdate resolves — the
// check is race-free.
async function applyUpdates(updates) {
const before = errorEpoch;
for (const u of updates) { await applyUpdate(u); }
if (errorEpoch === before) clearErrorOverlay();
}
function handle(raw) {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) {
case 'update': applyUpdates(msg.updates || []); break;
case 'css-update': updateCSS(msg.path); break;
case 'full-reload': fullReload(); break;
case 'error': showErrorOverlay(msg.err || {}); break;
}
}
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(proto + '://' + location.host + '/@hmr/ws');
ws.addEventListener('message', (e) => handle(e.data));
ws.addEventListener('open', () => console.log('[hmr] connected'));
ws.addEventListener('close', () => { console.log('[hmr] connection lost, retrying...'); setTimeout(connect, 1000); });
ws.addEventListener('error', () => ws.close());
}
connect();
`

View File

@@ -0,0 +1,32 @@
//go:build dev
package bundler
import (
"strings"
"testing"
esbuild "github.com/evanw/esbuild/pkg/api"
)
// hmrClientJS is a hand-written JS blob served to the browser; a syntax slip in it
// silently breaks every hot update, and the headless-browser smoke test is skipped
// off Linux/macOS. Parse it as an ES module here so a bad edit fails at test time,
// and assert the recompile-indicator hooks are wired.
func TestHMRClientJSValid(t *testing.T) {
res := esbuild.Transform(hmrClientJS, esbuild.TransformOptions{
Loader: esbuild.LoaderJS,
Format: esbuild.FormatESModule,
LogLevel: esbuild.LogLevelSilent,
})
if len(res.Errors) > 0 {
msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{})
t.Fatalf("HMR client JS failed to parse:\n%s", strings.Join(msgs, "\n"))
}
for _, want := range []string{"createHotContext", "hmrBusyStart", "hmrBusyEnd", "recompiling",
"showErrorOverlay", "clearErrorOverlay", "errorEpoch"} {
if !strings.Contains(hmrClientJS, want) {
t.Errorf("HMR client missing %q", want)
}
}
}

188
go/bundler/hmr_e2e_test.go Normal file
View File

@@ -0,0 +1,188 @@
//go:build dev
package bundler
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// A `?url` import must resolve to a shim module whose default export is the raw
// /@fs/ file URL — not the file itself (which would fail with "no default export",
// as the pdfjs worker did and cascaded into a stuck loading spinner).
func TestDevServerAssetURL(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
frontendAbs, _ := filepath.Abs(frontendDir)
tmpSrc := evalSymlinks(t.TempDir())
os.WriteFile(filepath.Join(tmpSrc, "uses-worker.tsx"),
[]byte(`import u from "pdfjs-dist/build/pdf.worker.min.mjs?url";
export const url = u;`), 0o644)
eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc,
vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(),
vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps}
out, err := d.transformModule(filepath.Join(tmpSrc, "uses-worker.tsx"))
if err != nil {
t.Fatalf("transform: %v", err)
}
const shimURL = "/@url/vendor/pdfjs-dist/build/pdf.worker.min.mjs"
if !strings.Contains(string(out), shimURL) {
t.Fatalf("?url import not rewritten to the shim path %q:\n%s", shimURL, out)
}
req := httptest.NewRequest("GET", shimURL, nil)
w := httptest.NewRecorder()
d.serveAssetURL(w, req)
body := w.Body.String()
const want = `export default "/@fs/vendor/pdfjs-dist/build/pdf.worker.min.mjs"`
if w.Code != 200 || !strings.Contains(body, want) {
t.Fatalf("shim module = %q (status %d), want default export of the /@fs/ URL", body, w.Code)
}
}
// resolveSource must mirror esbuild's resolution: a `.js` specifier for a sibling
// .ts/.tsx, extensionless specifiers, and directory index files.
func TestResolveSourceExtensions(t *testing.T) {
dir := t.TempDir()
write := func(rel string) {
p := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("export default 1;"), 0o644)
}
write("Foo.tsx")
write("Bar.ts")
write("baz/index.ts")
write("Real.js")
cases := []struct{ spec, want string }{
{"./Foo.js", "Foo.tsx"}, // .js specifier -> sibling .tsx
{"./Foo.tsx", "Foo.tsx"}, // exact
{"./Bar", "Bar.ts"}, // extensionless
{"./baz", "baz/index.ts"}, // directory index
{"./Real.js", "Real.js"}, // real .js wins
{"./missing.js", ""}, // dangling
}
for _, c := range cases {
got := resolveSource(dir, c.spec)
want := ""
if c.want != "" {
want = filepath.Join(dir, filepath.FromSlash(c.want))
}
if got != want {
t.Errorf("resolveSource(%q) = %q, want %q", c.spec, got, want)
}
}
}
// End-to-end over the dev server's HTTP surface: the SPA entry, a .tsx component,
// a vendor bundle, the client runtime, the import map, and graph-driven update vs.
// full-reload decisions — all without a database or the full server.
func TestDevServerEndToEnd(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
d, err := newDevServer()
if err != nil {
t.Fatalf("newDevServer: %v", err)
}
get := func(path string) (int, string) {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
switch {
case strings.HasPrefix(path, srcURLPrefix):
d.serveModule(w, req)
case strings.HasPrefix(path, vendorURLPrefix):
d.serveVendor(w, req)
case path == "/@hmr/client":
d.serveClient(w, req)
}
return w.Code, w.Body.String()
}
// --- SPA entry: hot bootstrap + bare imports kept + relative imports rewritten
code, app := get(srcURLPrefix + "app.ts")
if code != 200 {
t.Fatalf("app.ts status %d:\n%s", code, app)
}
for _, want := range []string{
`__createHotContext("/@src/app.ts")`, // hot bootstrap
`/@hmr/client`, // client import injected
`"solid-js/web"`, // bare specifier preserved for import map
`"@solidjs/router"`, // bare specifier preserved
`/@src/routes/app-routes.ts`, // relative import rewritten
`/@src/layouts/AppLayout.ts`, // relative import rewritten
} {
if !strings.Contains(app, want) {
t.Errorf("app.ts missing %q", want)
}
}
// --- a .tsx component: Solid + solid-refresh instrumentation
code, alerts := get(srcURLPrefix + "ui/Alerts.tsx")
if code != 200 {
t.Fatalf("Alerts.tsx status %d:\n%s", code, alerts)
}
for _, want := range []string{
`__createHotContext("/@src/ui/Alerts.tsx")`,
`solid-refresh`, // runtime import
"import.meta.hot", // esm HMR accept
} {
if !strings.Contains(alerts, want) {
t.Errorf("Alerts.tsx missing %q", want)
}
}
// --- vendor: solid-js/web must import "solid-js" externally (single instance)
code, web := get(vendorURLPrefix + "solid-js/web.js")
if code != 200 {
t.Fatalf("vendor solid-js/web status %d", code)
}
if !strings.Contains(web, `"solid-js"`) {
t.Errorf("solid-js/web should keep a bare `solid-js` import (shared instance)")
}
// --- client runtime is an ES module exporting createHotContext
code, client := get("/@hmr/client")
if code != 200 || !strings.Contains(client, "export function createHotContext") {
t.Errorf("client runtime missing createHotContext (status %d)", code)
}
// --- import map maps the key vendored specifiers to /@vendor/ URLs
var im struct {
Imports map[string]string `json:"imports"`
}
if err := json.Unmarshal([]byte(d.importMapJSON), &im); err != nil {
t.Fatalf("import map JSON: %v", err)
}
for _, spec := range []string{"solid-js", "solid-js/web", "@solidjs/router", "solid-refresh"} {
if got := im.Imports[spec]; got != vendorURLPrefix+spec+".js" {
t.Errorf("import map[%q] = %q, want %q", spec, got, vendorURLPrefix+spec+".js")
}
}
// --- graph: editing a routes table (non-boundary, reachable from the entry)
// forces a full reload; editing a .tsx component is a hot update.
routes := filepath.Join(d.srcRoot, "routes", "app-routes.ts")
if _, _, reload := d.graph.invalidate(routes); !reload {
t.Errorf("changing app-routes.ts should force a full reload")
}
alertsPath := filepath.Join(d.srcRoot, "ui", "Alerts.tsx")
if boundaries, _, reload := d.graph.invalidate(alertsPath); reload || len(boundaries) == 0 {
t.Errorf("changing Alerts.tsx should be a hot update, got reload=%v boundaries=%v", reload, boundaries)
}
}

View File

@@ -0,0 +1,78 @@
//go:build dev
package bundler
import (
"errors"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
esbuild "github.com/evanw/esbuild/pkg/api"
)
// errorModule must emit a valid ES module that imports the client's
// showErrorOverlay and calls it with the error text + file, so a failed
// transform pops the compile-error overlay instead of just logging.
func TestErrorModuleShape(t *testing.T) {
src := errorModule("ui/Broken.tsx", errors.New("Unexpected \"}\" at line 3"))
for _, want := range []string{
`from "/@hmr/client"`,
"showErrorOverlay",
"__hmrShowError(",
"ui/Broken.tsx",
`Unexpected`,
} {
if !strings.Contains(src, want) {
t.Errorf("errorModule() missing %q:\n%s", want, src)
}
}
// It must parse as an ES module (the payload is embedded JSON-as-JS).
res := esbuild.Transform(src, esbuild.TransformOptions{
Loader: esbuild.LoaderJS, Format: esbuild.FormatESModule, LogLevel: esbuild.LogLevelSilent,
})
if len(res.Errors) > 0 {
msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{})
t.Fatalf("errorModule() is not valid JS:\n%s\n--- source ---\n%s", strings.Join(msgs, "\n"), src)
}
}
// A source file that fails to compile must be served as the overlay stub (HTTP
// 200, not a 500 that would tear the module graph), so the browser shows the
// error and the next save can recover.
func TestServeModuleCompileErrorServesOverlay(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
frontendAbs, _ := filepath.Abs(frontendDir)
tmpSrc := evalSymlinks(t.TempDir())
// Deliberately broken JSX: an unclosed tag the compiler/esbuild must reject.
os.WriteFile(filepath.Join(tmpSrc, "Broken.tsx"),
[]byte("export default function Broken() {\n return <div><span></div>;\n}\n"), 0o644)
eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc,
vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(),
vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps}
req := httptest.NewRequest("GET", srcURLPrefix+"Broken.tsx", nil)
w := httptest.NewRecorder()
d.serveModule(w, req)
if w.Code != 200 {
t.Fatalf("broken module served status %d, want 200 (overlay stub)", w.Code)
}
body := w.Body.String()
for _, want := range []string{"showErrorOverlay", `from "/@hmr/client"`, "Broken.tsx"} {
if !strings.Contains(body, want) {
t.Errorf("overlay stub missing %q:\n%s", want, body)
}
}
}

497
go/bundler/hmr_server.go Normal file
View File

@@ -0,0 +1,497 @@
//go:build dev
package bundler
// The development HMR server: it serves the SPA source tree as unbundled native
// ES modules (transformed on the fly), tracks the module import graph, watches
// the filesystem, and pushes hot-update / reload messages to the browser over a
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
// a non-boundary module bubbles up to a full page reload.
//
// This is the reason internal/bundler grew a `dev` build tag: the whole HMR
// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go,
// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the
// production server and the plain `cmd/bundle` build carry none of it.
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
esbuild "github.com/evanw/esbuild/pkg/api"
)
// devURLPrefix roots the served source tree: GET /@src/<rel> serves the
// transformed module frontend/src/<rel>.
const (
srcURLPrefix = "/@src/" // transformed source modules
assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL
fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at)
)
type devServer struct {
hub *hub
frontend string // abs frontend/
srcRoot string // abs frontend/src
vendor string // abs frontend/vendor
output string // abs wwwroot/
graph *moduleGraph
importMapJSON string
vendorEntrypoints map[string]string
vendorMu sync.Mutex
vendorCache map[string][]byte // /@vendor/<spec>.js -> bundled bytes
cssTrigger chan struct{} // coalesced CSS rebuild requests (buffered, size 1)
}
// StartDevHMR registers the dev routes on mux, launches the filesystem watcher,
// and returns the import map (JSON object body) the SPA shell must inline so bare
// specifiers resolve to the single vendored copies. Called only from cmd/server's
// `dev`-tagged shim.
func StartDevHMR(mux *http.ServeMux) (importMap string, err error) {
d, err := newDevServer()
if err != nil {
return "", err
}
d.register(mux)
go d.watch()
fmt.Println("HMR dev server: serving native-ESM source from /@src/, WebSocket at /@hmr/ws")
return d.importMapJSON, nil
}
// newDevServer constructs the dev server (abs paths, vendor manifest, import map)
// without registering routes or starting the watcher — the split lets tests drive
// the handlers directly.
func newDevServer() (*devServer, error) {
frontendAbs, err := filepath.Abs(frontendDir)
if err != nil {
return nil, err
}
outputAbs, err := filepath.Abs(outputDir)
if err != nil {
return nil, err
}
// esbuild reports realpaths (symlinks resolved), so the roots must be too or
// the containment/URL math breaks on symlinked trees (e.g. macOS /var→/private/var).
frontendAbs = evalSymlinks(frontendAbs)
outputAbs = evalSymlinks(outputAbs)
d := &devServer{
hub: newHub(),
frontend: frontendAbs,
srcRoot: filepath.Join(frontendAbs, "src"),
vendor: filepath.Join(frontendAbs, "vendor"),
output: outputAbs,
graph: newModuleGraph(),
vendorCache: map[string][]byte{},
cssTrigger: make(chan struct{}, 1),
}
eps, err := loadVendorManifest(d.vendor)
if err != nil {
return nil, fmt.Errorf("loading vendor manifest: %w", err)
}
d.vendorEntrypoints = eps
d.importMapJSON = d.buildImportMap()
return d, nil
}
func (d *devServer) register(mux *http.ServeMux) {
mux.HandleFunc(srcURLPrefix, d.serveModule)
mux.HandleFunc(vendorURLPrefix, d.serveVendor)
mux.HandleFunc(assetURLPrefix, d.serveAssetURL)
mux.HandleFunc(fsURLPrefix, d.serveFS)
mux.HandleFunc("/@hmr/client", d.serveClient)
mux.HandleFunc("/@hmr/ws", d.hub.ServeWS)
}
// serveModule transforms and serves one source module as native ESM.
func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, srcURLPrefix)
abs := filepath.Join(d.srcRoot, filepath.FromSlash(rel))
// Contain the request to the source tree.
if !within(d.srcRoot, abs) {
http.NotFound(w, r)
return
}
if _, statErr := os.Stat(abs); statErr != nil {
http.NotFound(w, r)
return
}
code, err := d.transformModule(abs)
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
if err != nil {
// Surface the failure as a browser overlay rather than a hard 500 that
// would break the module graph; the next save re-runs the transform.
fmt.Fprintf(os.Stderr, "HMR transform %s: %v\n", rel, err)
w.Write([]byte(errorModule(rel, err)))
return
}
w.Write(code)
}
// transformModule runs esbuild over a single entry file with all imports marked
// external (so only this file is transformed) and rewritten to dev URLs, then
// prepends the import.meta.hot bootstrap. .tsx/.jsx are Solid+refresh compiled
// via the goja pipeline first.
func (d *devServer) transformModule(abs string) ([]byte, error) {
result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{abs},
Bundle: true,
Write: false,
Format: esbuild.FormatESModule,
Platform: esbuild.PlatformBrowser,
Target: esbuild.ES2022,
Sourcemap: esbuild.SourceMapInline,
SourcesContent: esbuild.SourcesContentInclude,
LogLevel: esbuild.LogLevelSilent,
// Same compile-time env define as the production bundle, so env.ts reads
// the baked value under HMR too (see esbuildDefine).
Define: esbuildDefine(),
// Order matters: the Solid/JSX loader and the export shim handle the entry
// file's contents; the external-rewrite resolver must be able to see every
// import, so it runs last (its OnResolve filter is `.*`).
Plugins: []esbuild.Plugin{
d.solidRefreshLoadPlugin(),
defaultExportShimPlugin(),
d.externalRewritePlugin(),
},
})
if len(result.Errors) > 0 {
msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
return nil, fmt.Errorf("%s", strings.Join(msgs, "\n"))
}
if len(result.OutputFiles) == 0 {
return nil, fmt.Errorf("no output")
}
selfURL := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs))
prelude := "import { createHotContext as __createHotContext } from \"/@hmr/client\";\n" +
"import.meta.hot = __createHotContext(" + jsString(selfURL) + ");\n"
return append([]byte(prelude), result.OutputFiles[0].Contents...), nil
}
// solidRefreshLoadPlugin Solid-compiles .tsx/.jsx via the goja pipeline WITH
// solid-refresh instrumentation, passing the src-relative path as the stable id
// solid-refresh keys its HMR registry on (so it survives across recompiles).
func (d *devServer) solidRefreshLoadPlugin() esbuild.Plugin {
return esbuild.Plugin{
Name: "dev-solid-refresh",
Setup: func(b esbuild.PluginBuild) {
b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(tsx|jsx)$`}, func(a esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
data, err := os.ReadFile(a.Path)
if err != nil {
return esbuild.OnLoadResult{}, err
}
id := filepath.ToSlash(mustRel(d.srcRoot, a.Path))
out, err := CompileDev(string(data), id)
if err != nil {
return esbuild.OnLoadResult{}, err
}
// Load as TS, not JS: esbuild only re-emits (and thus rewrites this
// module's external import paths to our /@src/ URLs) when it has to
// transpile. A JS loader leaves the already-JS CompileDev output
// untouched, leaking the raw ./x.js specifiers to the browser. esbuild
// chains solid-refresh's inline sourcemap into its own, so the browser
// still debugs against the original .tsx.
loader := esbuild.LoaderTS
dir := filepath.Dir(a.Path)
return esbuild.OnLoadResult{Contents: &out, Loader: loader, ResolveDir: dir}, nil
})
},
}
}
// externalRewritePlugin marks every non-entry import external and rewrites its
// path to a dev URL: relative/absolute → /@src/<resolved> (recording a graph
// edge), `?url` → /@fs/<file> raw passthrough, bare → left as-is for the import
// map. esbuild emits the returned path verbatim, so we control the URL.
func (d *devServer) externalRewritePlugin() esbuild.Plugin {
return esbuild.Plugin{
Name: "dev-external-rewrite",
Setup: func(b esbuild.PluginBuild) {
b.OnResolve(esbuild.OnResolveOptions{Filter: `.*`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
if a.Kind == esbuild.ResolveEntryPoint {
return esbuild.OnResolveResult{}, nil // let esbuild load the entry
}
spec := a.Path
// `import x from "...?url"` — emit the referenced file as a raw asset.
if strings.HasSuffix(spec, "?url") {
real := strings.TrimSuffix(spec, "?url")
if target := d.resolveAsset(a.ResolveDir, real); target != "" {
return esbuild.OnResolveResult{Path: target, External: true}, nil
}
}
// Bare specifier → import map (single vendored copy).
if !strings.HasPrefix(spec, ".") && !filepath.IsAbs(spec) {
return esbuild.OnResolveResult{Path: spec, External: true}, nil
}
// Relative/absolute → resolve to the real source file and rewrite to
// its /@src/ URL, recording the importer→import edge for HMR.
resolved := resolveSource(a.ResolveDir, spec)
if resolved == "" || !within(d.srcRoot, resolved) {
// Unknown relative import: leave it and let the browser 404 loudly.
return esbuild.OnResolveResult{Path: spec, External: true}, nil
}
if a.Importer != "" {
d.graph.recordEdge(a.Importer, resolved)
}
url := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, resolved))
if v := d.graph.versionOf(resolved); v > 0 {
url += fmt.Sprintf("?t=%d", v)
}
return esbuild.OnResolveResult{Path: url, External: true}, nil
})
},
}
}
// resolveAsset resolves a `?url` target (relative to importer, or a vendored
// subpath) to a /@url/ shim-module path rooted at the frontend dir. The shim's
// default export is the raw /@fs/ URL — matching esbuild's file-loader `?url`
// semantics, where `import u from "x?url"` binds u to the asset's URL string, not
// the module's exports.
func (d *devServer) resolveAsset(resolveDir, spec string) string {
var abs string
if strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) {
abs = filepath.Join(resolveDir, spec)
} else {
abs = filepath.Join(d.vendor, filepath.FromSlash(spec)) // vendored subpath
}
if !within(d.frontend, abs) || !fileExists(abs) {
return ""
}
return assetURLPrefix + filepath.ToSlash(mustRel(d.frontend, abs))
}
// serveAssetURL serves the `?url` shim module: `export default "<raw file URL>"`.
func (d *devServer) serveAssetURL(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, assetURLPrefix)
abs := filepath.Join(d.frontend, filepath.FromSlash(rel))
if !within(d.frontend, abs) || !fileExists(abs) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
fmt.Fprintf(w, "export default %s;\n", jsString(fsURLPrefix+rel))
}
// serveFS serves a raw file under the frontend dir (the target of a `?url` shim,
// e.g. the pdfjs worker, which must load from a real URL for import.meta.url).
func (d *devServer) serveFS(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, fsURLPrefix)
abs := filepath.Join(d.frontend, filepath.FromSlash(rel))
if !within(d.frontend, abs) || !fileExists(abs) {
http.NotFound(w, r)
return
}
// Pin the JS MIME so module workers (.mjs) aren't rejected for a text/plain type.
switch strings.ToLower(filepath.Ext(abs)) {
case ".mjs", ".js":
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
}
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, abs)
}
// ---- module graph ----------------------------------------------------------
// moduleGraph tracks importer→import edges and per-module versions so a change
// can be propagated to the nearest self-accepting boundary (a .tsx/.jsx compiled
// with solid-refresh) or, failing that, a full reload. Keys are absolute paths.
type moduleGraph struct {
mu sync.Mutex
importers map[string]map[string]bool // module -> set of modules that import it
version map[string]int64 // module -> current version (0 = never changed)
counter int64
}
func newModuleGraph() *moduleGraph {
return &moduleGraph{importers: map[string]map[string]bool{}, version: map[string]int64{}}
}
func (g *moduleGraph) recordEdge(importer, imported string) {
g.mu.Lock()
defer g.mu.Unlock()
set := g.importers[imported]
if set == nil {
set = map[string]bool{}
g.importers[imported] = set
}
set[importer] = true
}
func (g *moduleGraph) versionOf(module string) int64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.version[module]
}
// invalidate walks up from the changed file to the nearest HMR boundaries,
// bumping the version of every module on the path (so a re-imported boundary
// re-fetches the changed dep, not a cached copy). It returns the boundary URLs to
// re-import and the shared version, or fullReload if the change reaches a
// non-accepting root (the entry, a routes table).
func (g *moduleGraph) invalidate(changed string) (boundaries []string, version int64, fullReload bool) {
g.mu.Lock()
defer g.mu.Unlock()
g.counter++
v := g.counter
visited := map[string]bool{}
queue := []string{changed}
for len(queue) > 0 {
m := queue[0]
queue = queue[1:]
if visited[m] {
continue
}
visited[m] = true
g.version[m] = v
if isHMRBoundary(m) {
boundaries = append(boundaries, m)
continue // don't climb past a self-accepting boundary
}
imps := g.importers[m]
if len(imps) == 0 {
fullReload = true // reached a root that can't accept -> reload
continue
}
for imp := range imps {
queue = append(queue, imp)
}
}
if fullReload {
return nil, v, true
}
return boundaries, v, false
}
// isHMRBoundary reports whether a module can self-accept — only solid-refresh
// -instrumented JSX (.tsx/.jsx). .ts/.js (incl. the solid-js/html app pages)
// bubble to a full reload.
func isHMRBoundary(path string) bool {
switch filepath.Ext(path) {
case ".tsx", ".jsx":
return true
default:
return false
}
}
// ---- helpers ---------------------------------------------------------------
// resolveSource replicates esbuild's resolution of a relative import to a real
// source file, including the project's `.js`-specifier-for-a-.ts-file convention
// (see import_check.go) and extensionless / index resolution.
func resolveSource(resolveDir, spec string) string {
base := filepath.Join(resolveDir, filepath.FromSlash(spec))
if fileExists(base) {
return base
}
// `./Foo.js` may name a sibling .ts/.tsx/.jsx (esbuild resolves it silently).
if strings.HasSuffix(base, ".js") {
stem := strings.TrimSuffix(base, ".js")
for _, ext := range []string{".ts", ".tsx", ".jsx"} {
if fileExists(stem + ext) {
return stem + ext
}
}
}
// Extensionless specifier.
for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} {
if fileExists(base + ext) {
return base + ext
}
}
// Directory index.
for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} {
if idx := filepath.Join(base, "index"+ext); fileExists(idx) {
return idx
}
}
return ""
}
func fileExists(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}
// evalSymlinks resolves symlinks so paths compare equal to esbuild's realpaths;
// returns the input unchanged if it can't be resolved (e.g. doesn't exist yet).
func evalSymlinks(p string) string {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r
}
return p
}
// within reports whether abs is inside root (after cleaning), guarding against
// `..` traversal out of the served tree.
func within(root, abs string) bool {
rel, err := filepath.Rel(root, abs)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func mustRel(root, abs string) string {
rel, err := filepath.Rel(root, abs)
if err != nil {
return abs
}
return rel
}
// jsString renders s as a double-quoted JS string literal.
func jsString(s string) string {
b := strings.Builder{}
b.WriteByte('"')
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
default:
b.WriteRune(r)
}
}
b.WriteByte('"')
return b.String()
}
// errorModule returns an ES module that surfaces a compile/transform failure as
// a full-screen overlay in the browser (Vite-style) instead of breaking the page
// hard. It imports the HMR client's showErrorOverlay and calls it with the error
// text; because the stub evaluates synchronously as part of the importing
// boundary's module graph, the overlay appears the moment a broken module is
// (re-)imported — on first load or on a hot re-import. A later successful hot
// update clears it (see applyUpdates in hmr_client.go). rel is the src-relative
// path of the failing module, shown in the overlay header.
func errorModule(rel string, err error) string {
payload, jerr := json.Marshal(hmrError{Message: err.Error(), File: rel})
if jerr != nil {
return "console.error(" + jsString("[hmr] build error:\n"+err.Error()) + ");\n"
}
return "import { showErrorOverlay as __hmrShowError } from \"/@hmr/client\";\n" +
"__hmrShowError(" + string(payload) + ");\n"
}

118
go/bundler/hmr_vendor.go Normal file
View File

@@ -0,0 +1,118 @@
//go:build dev
package bundler
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
// wired up by an import map the SPA shell inlines. Because each specifier maps to
// exactly one URL, and every vendor bundle re-externalizes the OTHER vendored
// specifiers (rather than inlining them), solid-js stays a single runtime
// instance across the whole app — the invariant the prod bundler also guards.
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
esbuild "github.com/evanw/esbuild/pkg/api"
)
const vendorURLPrefix = "/@vendor/"
// buildImportMap returns the JSON `{"imports": {...}}` body the SPA shell inlines
// in <script type="importmap">, mapping every vendored bare specifier to its
// /@vendor/ URL.
func (d *devServer) buildImportMap() string {
imports := make(map[string]string, len(d.vendorEntrypoints))
for spec := range d.vendorEntrypoints {
imports[spec] = vendorURLPrefix + spec + ".js"
}
body, err := json.MarshalIndent(map[string]any{"imports": imports}, "", " ")
if err != nil {
return `{"imports":{}}`
}
return string(body)
}
// serveVendor serves a pre-bundled vendored package (lazily built + cached).
func (d *devServer) serveVendor(w http.ResponseWriter, r *http.Request) {
spec := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, vendorURLPrefix), ".js")
relFile, ok := d.vendorEntrypoints[spec]
if !ok {
http.NotFound(w, r)
return
}
d.vendorMu.Lock()
bundled, cached := d.vendorCache[r.URL.Path]
d.vendorMu.Unlock()
if !cached {
b, err := d.bundleVendor(relFile)
if err != nil {
http.Error(w, "vendor bundle failed: "+err.Error(), http.StatusInternalServerError)
return
}
d.vendorMu.Lock()
d.vendorCache[r.URL.Path] = b
d.vendorMu.Unlock()
bundled = b
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
// Vendor rarely changes; let the browser cache within a session.
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(bundled)
}
// bundleVendor bundles one vendored package entrypoint into a single ESM file,
// re-externalizing the OTHER vendored specifiers so they resolve (once) through
// the import map. Mirrors the prod vendor resolution (NodePaths + development
// condition) so the dev copy matches the shipped one.
func (d *devServer) bundleVendor(relFile string) ([]byte, error) {
entry := filepath.Join(d.vendor, filepath.FromSlash(relFile))
result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{entry},
Bundle: true,
Write: false,
Format: esbuild.FormatESModule,
Target: esbuild.ES2022,
Platform: esbuild.PlatformBrowser,
Conditions: []string{"development"},
NodePaths: []string{d.vendor},
Sourcemap: esbuild.SourceMapInline,
LogLevel: esbuild.LogLevelSilent,
Plugins: []esbuild.Plugin{d.vendorSharedExternalPlugin()},
})
if len(result.Errors) > 0 {
msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
return nil, fmt.Errorf("%s", strings.Join(msgs, "\n"))
}
if len(result.OutputFiles) == 0 {
return nil, fmt.Errorf("no output")
}
return result.OutputFiles[0].Contents, nil
}
// vendorSharedExternalPlugin marks a bare import external ONLY when it's an exact
// vendored entrypoint (thus resolvable via the import map). That keeps shared
// singletons — above all solid-js — as one instance across every vendor bundle,
// while deep subpaths and non-vendored transitive deps get bundled in.
func (d *devServer) vendorSharedExternalPlugin() esbuild.Plugin {
return esbuild.Plugin{
Name: "dev-vendor-shared-external",
Setup: func(b esbuild.PluginBuild) {
b.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
if a.Kind == esbuild.ResolveEntryPoint {
return esbuild.OnResolveResult{}, nil
}
if _, ok := d.vendorEntrypoints[a.Path]; ok {
return esbuild.OnResolveResult{Path: a.Path, External: true}, nil
}
return esbuild.OnResolveResult{}, nil // bundle via NodePaths
})
},
}
}

158
go/bundler/hmr_watch.go Normal file
View File

@@ -0,0 +1,158 @@
//go:build dev
package bundler
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
// (no external dependency). A source-module change is turned into an HMR update
// or a full reload via the module graph; a CSS/Tailwind change triggers a
// (coalesced) stylesheet rebuild and hot-swap. The public-route manifest is
// regenerated on edit, then the page reloads.
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// pollInterval is the mtime scan cadence. Kept tight so save→update latency isn't
// dominated by detection lag: the WalkDir+Stat over frontend/src+css is sub-ms, so
// scanning this often is cheap. (For zero detection lag, swap the poll for native
// FS events — deliberately avoided to keep the watcher dependency-free.)
const pollInterval = 50 * time.Millisecond
func (d *devServer) watch() {
go d.cssLoop()
roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")}
mtimes := map[string]time.Time{}
d.scan(roots, mtimes, nil) // seed: record current state, emit nothing
for {
time.Sleep(pollInterval)
var changed []string
d.scan(roots, mtimes, func(p string) { changed = append(changed, p) })
if len(changed) > 0 {
d.handleChanges(changed)
}
}
}
// scan walks roots, calling onchange for every file whose mtime advanced since
// the last scan (or is new). mtimes is updated in place.
func (d *devServer) scan(roots []string, mtimes map[string]time.Time, onchange func(string)) {
for _, root := range roots {
filepath.WalkDir(root, func(p string, entry os.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return nil
}
switch filepath.Ext(p) {
case ".ts", ".tsx", ".js", ".jsx", ".css":
default:
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
mt := info.ModTime()
if prev, ok := mtimes[p]; !ok || mt.After(prev) {
mtimes[p] = mt
// onchange is nil on the seed pass, so nothing is reported until the
// baseline is recorded; afterwards every new/changed file is reported.
if onchange != nil {
onchange(p)
}
}
return nil
})
}
}
func (d *devServer) handleChanges(changed []string) {
pagesManifestAbs := filepath.Join(d.frontend, filepath.FromSlash(pagesManifest))
cssDirty := false
srcDirty := false
for _, p := range changed {
base := filepath.Base(p)
// Generated files are written by the tools below; ignore to avoid loops.
if strings.HasSuffix(base, ".gen.ts") || strings.HasSuffix(base, ".gen.go") {
continue
}
if p == pagesManifestAbs {
// The public-page manifest drives generated route tables — regenerate,
// then reload (these pages are live-reload scope, not component HMR).
if err := generatePublicRoutes(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] regenerating public routes: %v\n", err)
}
d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
return
}
switch filepath.Ext(p) {
case ".css":
cssDirty = true
case ".ts", ".tsx", ".js", ".jsx":
d.hmrJS(p)
cssDirty = true // a class may have been added/removed
srcDirty = true
}
}
if cssDirty {
select {
case d.cssTrigger <- struct{}{}:
default: // a rebuild is already queued
}
}
if srcDirty {
// Proactively re-render the public pages' SSR so the no-JS ("static")
// version served on the next reload is already current, not rendered lazily.
RewarmDevPublicPages()
}
}
// hmrJS turns one changed source module into a hot update or a full reload.
func (d *devServer) hmrJS(abs string) {
rel := filepath.ToSlash(mustRel(d.srcRoot, abs))
boundaries, version, reload := d.graph.invalidate(abs)
if reload {
fmt.Printf("[hmr] full reload (%s)\n", rel)
d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
return
}
if len(boundaries) == 0 {
return // not on the current page's graph — nothing to do
}
updates := make([]hmrUpdate, 0, len(boundaries))
for _, b := range boundaries {
updates = append(updates, hmrUpdate{
Path: srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, b)),
Timestamp: version,
})
}
fmt.Printf("[hmr] update %s -> %d boundary(ies)\n", rel, len(boundaries))
d.hub.broadcastJSON(hmrMessage{Type: "update", Updates: updates})
}
// cssLoop serializes and coalesces CSS rebuilds, hot-swapping the stylesheet when
// each completes.
func (d *devServer) cssLoop() {
for range d.cssTrigger {
if _, err := bundleSPACSS(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] CSS rebuild failed: %v\n", err)
} else {
d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/bundle.min.css"})
}
// Public pages hot-reload too, off their own stylesheet.
if _, err := bundlePublicCSS(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] public CSS rebuild failed: %v\n", err)
} else {
d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/public.bundle.min.css"})
}
}
}

235
go/bundler/hmr_ws.go Normal file
View File

@@ -0,0 +1,235 @@
//go:build dev
package bundler
// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
// push of HMR messages. We hand-roll it (rather than add a dependency) because
// the surface we need is tiny: the handshake, unmasked server text frames, and a
// read loop that answers pings and notices close. No per-message compression, no
// fragmentation, no client→server application data. All of internal/bundler's
// HMR support is behind `//go:build dev`, so prod builds compile none of it.
import (
"bufio"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"sync"
)
// wsGUID is the RFC 6455 magic value concatenated with Sec-WebSocket-Key to
// derive the accept token.
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
type hub struct {
mu sync.Mutex
clients map[*wsClient]struct{}
}
func newHub() *hub { return &hub{clients: map[*wsClient]struct{}{}} }
type wsClient struct {
conn net.Conn
brw *bufio.ReadWriter
wmu sync.Mutex // serialize frame writes across broadcast + pong
}
// ServeWS upgrades an HTTP/1.1 request to a WebSocket and registers the client.
func (h *hub) ServeWS(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Sec-WebSocket-Key")
if key == "" {
http.Error(w, "expected a WebSocket handshake", http.StatusBadRequest)
return
}
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "connection does not support hijacking", http.StatusInternalServerError)
return
}
conn, brw, err := hj.Hijack()
if err != nil {
return
}
accept := computeAccept(key)
if _, err := brw.WriteString(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: " + accept + "\r\n\r\n",
); err != nil {
conn.Close()
return
}
if err := brw.Flush(); err != nil {
conn.Close()
return
}
c := &wsClient{conn: conn, brw: brw}
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
go c.readLoop(h)
}
// broadcast writes a text frame to every connected client, dropping any that
// error (disconnected tab).
func (h *hub) broadcast(payload []byte) {
h.mu.Lock()
clients := make([]*wsClient, 0, len(h.clients))
for c := range h.clients {
clients = append(clients, c)
}
h.mu.Unlock()
for _, c := range clients {
if err := c.writeText(payload); err != nil {
h.drop(c)
}
}
}
// broadcastJSON marshals v and broadcasts it as a text frame.
func (h *hub) broadcastJSON(v any) {
b, err := json.Marshal(v)
if err != nil {
fmt.Fprintf(os.Stderr, "devhmr: marshal ws message: %v\n", err)
return
}
h.broadcast(b)
}
func (h *hub) drop(c *wsClient) {
h.mu.Lock()
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
c.conn.Close()
}
h.mu.Unlock()
}
// clientCount reports how many browsers are currently connected.
func (h *hub) clientCount() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.clients)
}
// readLoop consumes client frames only to answer pings and to notice a close or
// dead connection, at which point the client is dropped. Application data from
// the client is ignored — this channel is server→browser only.
func (c *wsClient) readLoop(h *hub) {
defer h.drop(c)
for {
op, payload, err := readFrame(c.brw.Reader)
if err != nil {
return
}
switch op {
case opClose:
c.writeFrame(opClose, payload)
return
case opPing:
if c.writeFrame(opPong, payload) != nil {
return
}
}
}
}
func (c *wsClient) writeText(payload []byte) error { return c.writeFrame(opText, payload) }
// opcodes we handle.
const (
opText byte = 0x1
opClose byte = 0x8
opPing byte = 0x9
opPong byte = 0xA
)
// writeFrame writes a single unmasked, unfragmented frame (server frames must
// not be masked). Writes are serialized so a broadcast and a pong can't interleave.
func (c *wsClient) writeFrame(opcode byte, payload []byte) error {
c.wmu.Lock()
defer c.wmu.Unlock()
header := make([]byte, 0, 10)
header = append(header, 0x80|opcode) // FIN + opcode
n := len(payload)
switch {
case n <= 125:
header = append(header, byte(n))
case n <= 0xFFFF:
header = append(header, 126, byte(n>>8), byte(n))
default:
header = append(header, 127)
for i := 7; i >= 0; i-- {
header = append(header, byte(n>>(8*i)))
}
}
if _, err := c.brw.Write(header); err != nil {
return err
}
if _, err := c.brw.Write(payload); err != nil {
return err
}
return c.brw.Flush()
}
// readFrame reads one frame, unmasking the client payload (client→server frames
// are always masked). Returns the opcode and payload.
func readFrame(r *bufio.Reader) (opcode byte, payload []byte, err error) {
var h [2]byte
if _, err = io.ReadFull(r, h[:]); err != nil {
return
}
opcode = h[0] & 0x0F
masked := h[1]&0x80 != 0
n := int(h[1] & 0x7F)
switch n {
case 126:
var ext [2]byte
if _, err = io.ReadFull(r, ext[:]); err != nil {
return
}
n = int(ext[0])<<8 | int(ext[1])
case 127:
var ext [8]byte
if _, err = io.ReadFull(r, ext[:]); err != nil {
return
}
n = 0
for _, b := range ext {
n = n<<8 | int(b)
}
}
var mask [4]byte
if masked {
if _, err = io.ReadFull(r, mask[:]); err != nil {
return
}
}
payload = make([]byte, n)
if _, err = io.ReadFull(r, payload); err != nil {
return
}
if masked {
for i := range payload {
payload[i] ^= mask[i%4]
}
}
return
}
// computeAccept derives the Sec-WebSocket-Accept response header from the key.
func computeAccept(key string) string {
s := sha1.Sum([]byte(key + wsGUID))
return base64.StdEncoding.EncodeToString(s[:])
}

View File

@@ -0,0 +1,88 @@
//go:build dev
package bundler
import (
"bufio"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// A real TCP client completes the WebSocket handshake against the hub (exercising
// the http.Hijacker path a recorder can't), then receives a broadcast frame. This
// covers the end-to-end push channel the browser HMR client relies on.
func TestWebSocketHandshakeAndBroadcast(t *testing.T) {
h := newHub()
mux := http.NewServeMux()
mux.HandleFunc("/@hmr/ws", h.ServeWS)
srv := httptest.NewServer(mux)
defer srv.Close()
conn, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
_, err = conn.Write([]byte(
"GET /@hmr/ws HTTP/1.1\r\n" +
"Host: " + srv.Listener.Addr().String() + "\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n",
))
if err != nil {
t.Fatalf("write handshake: %v", err)
}
br := bufio.NewReader(conn)
status, err := br.ReadString('\n')
if err != nil || !strings.Contains(status, "101") {
t.Fatalf("expected 101 Switching Protocols, got %q (err %v)", status, err)
}
var acceptOK bool
for {
line, err := br.ReadString('\n')
if err != nil {
t.Fatalf("reading headers: %v", err)
}
if strings.HasPrefix(line, "Sec-WebSocket-Accept:") &&
strings.Contains(line, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") {
acceptOK = true
}
if line == "\r\n" {
break
}
}
if !acceptOK {
t.Fatal("missing/incorrect Sec-WebSocket-Accept header")
}
// The client must be registered before we broadcast.
deadline := time.Now().Add(2 * time.Second)
for h.clientCount() == 0 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if h.clientCount() != 1 {
t.Fatalf("hub client count = %d, want 1", h.clientCount())
}
h.broadcastJSON(hmrMessage{Type: "full-reload"})
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
op, payload, err := readFrame(br)
if err != nil {
t.Fatalf("reading broadcast frame: %v", err)
}
if op != opText {
t.Errorf("broadcast opcode = %#x, want text", op)
}
if !strings.Contains(string(payload), `"full-reload"`) {
t.Errorf("broadcast payload = %q, want full-reload message", payload)
}
}

67
go/bundler/hmr_ws_test.go Normal file
View File

@@ -0,0 +1,67 @@
//go:build dev
package bundler
import (
"bufio"
"bytes"
"testing"
)
// The canonical handshake vector from RFC 6455 §1.3.
func TestComputeAccept(t *testing.T) {
got := computeAccept("dGhlIHNhbXBsZSBub25jZQ==")
const want = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
if got != want {
t.Fatalf("computeAccept = %q, want %q", got, want)
}
}
// A masked client frame round-trips through readFrame (payload unmasked, opcode
// preserved) — the path exercised when the browser sends a ping or close.
func TestReadFrameMaskedText(t *testing.T) {
payload := []byte("hello hmr")
mask := [4]byte{0x12, 0x34, 0x56, 0x78}
var buf bytes.Buffer
buf.WriteByte(0x80 | opText) // FIN + text
buf.WriteByte(0x80 | byte(len(payload))) // MASK + len
buf.Write(mask[:])
for i, b := range payload {
buf.WriteByte(b ^ mask[i%4])
}
op, got, err := readFrame(bufio.NewReader(&buf))
if err != nil {
t.Fatalf("readFrame: %v", err)
}
if op != opText {
t.Errorf("opcode = %#x, want %#x", op, opText)
}
if !bytes.Equal(got, payload) {
t.Errorf("payload = %q, want %q", got, payload)
}
}
// A server frame is written unmasked and parses back to the same payload.
func TestWriteFrameRoundTrip(t *testing.T) {
payload := bytes.Repeat([]byte("x"), 300) // exercises the 16-bit length path
var raw bytes.Buffer
c := &wsClient{brw: bufio.NewReadWriter(bufio.NewReader(nil), bufio.NewWriter(&raw))}
if err := c.writeText(payload); err != nil {
t.Fatalf("writeText: %v", err)
}
if raw.Bytes()[0] != (0x80 | opText) {
t.Fatalf("first byte = %#x, want %#x", raw.Bytes()[0], 0x80|opText)
}
if raw.Bytes()[1]&0x80 != 0 {
t.Fatalf("server frame must not set the mask bit")
}
op, got, err := readFrame(bufio.NewReader(&raw))
if err != nil {
t.Fatalf("readFrame: %v", err)
}
if op != opText || !bytes.Equal(got, payload) {
t.Errorf("round-trip mismatch: op=%#x len=%d", op, len(got))
}
}

View File

@@ -0,0 +1,87 @@
package bundler
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
// which lets misnamed specifiers slip through. This check catches them up
// front so every import path names the file that actually exists on disk.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
type importViolation struct {
file string
line int
spec string // the written specifier, e.g. "./Icons.js"
actual string // the corrected specifier, e.g. "./Icons.ts"
}
// reImportSpec matches a quoted relative module specifier ending in ".js".
var reImportSpec = regexp.MustCompile(`(["'])((?:\.\.?/)[^"']*?\.js)["']`)
// tsExtensions are the non-.js source extensions a .js specifier may
// actually resolve to, in esbuild's resolution order.
var tsExtensions = []string{".ts", ".tsx", ".jsx"}
// checkImportExtensions scans every JS/TS source under frontend/src for
// relative import specifiers written with a ".js" extension whose literal
// target does not exist but a sibling ".ts"/".tsx"/".jsx" does. esbuild
// resolves these transparently, so they bundle fine — but the import path
// lies about the file it points to. Specifiers that resolve to a real ".js"
// file, and dangling specifiers with no sibling at all, are left for esbuild.
func checkImportExtensions() []importViolation {
var violations []importViolation
srcRoot := filepath.Join(frontendDir, "src")
filepath.WalkDir(srcRoot, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
switch filepath.Ext(path) {
case ".js", ".ts", ".tsx", ".jsx":
default:
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
dir := filepath.Dir(path)
for i, line := range strings.Split(string(raw), "\n") {
for _, m := range reImportSpec.FindAllStringSubmatch(line, -1) {
spec := m[2]
if _, statErr := os.Stat(filepath.Join(dir, spec)); statErr == nil {
continue // resolves to a real .js file — fine
}
base := strings.TrimSuffix(spec, ".js")
for _, ext := range tsExtensions {
if _, statErr := os.Stat(filepath.Join(dir, base+ext)); statErr == nil {
violations = append(violations, importViolation{
file: path,
line: i + 1,
spec: spec,
actual: base + ext,
})
break
}
}
}
}
return nil
})
return violations
}
func reportImportViolations(violations []importViolation) {
fmt.Fprintf(os.Stderr, "\nImport extension check failed: %d import(s) use a .js extension for a non-.js file.\n", len(violations))
for _, v := range violations {
fmt.Fprintf(os.Stderr, " %s:%d: %q should be %q\n", v.file, v.line, v.spec, v.actual)
}
fmt.Fprintln(os.Stderr, "Rename each specifier to match the file's real extension.")
}

198
go/bundler/js.go Normal file
View File

@@ -0,0 +1,198 @@
package bundler
import (
"encoding/json"
"fmt"
"kjol/appenv"
"os"
"path/filepath"
"strconv"
"strings"
esbuild "github.com/evanw/esbuild/pkg/api"
)
// esbuildDefine returns the compile-time constants substituted into every JS
// bundle — both the production bundles here and the dev HMR per-module
// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time
// deployment environment (appenv.Environment) into the JS, where
// frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT
// define it (see internal/bundler/ssr.go), so the env badge renders only after
// the client takeover.
func esbuildDefine() map[string]string {
return map[string]string{
"__ENV_TYPE__": strconv.Quote(appenv.Environment),
}
}
// resolveEntryPoint returns the path (relative to frontendDir) of the
// SPA entry point, preferring app.ts over app.js.
func resolveEntryPoint() string {
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.ts")); err == nil {
return "src/app.ts"
}
return "src/app.js"
}
// bundleJS bundles the SPA entry (app.ts/app.js) into bundle.min.js.
func bundleJS() (bundleStats, error) {
return bundleJSEntry(filepath.Join(frontendDir, resolveEntryPoint()), "bundle.min.js")
}
// bundlePublicJS bundles the public-page takeover entry into
// public.bundle.min.js. This is the client half of the SSR'd public pages:
// it re-renders the Solid tree the server emitted (re-render takeover).
func bundlePublicJS() (bundleStats, error) {
return bundleJSEntry(filepath.Join(frontendDir, "src/public.tsx"), "public.bundle.min.js")
}
// bundleJSEntry runs esbuild with the default-export shim plugin for one entry
// point. Vendor modules (anything not a relative import) are kept external so
// the import map resolves them at runtime. .tsx/.jsx files are Solid-compiled by
// the Go-native compiler (Plugin); the SPA entry is solid-js/html tagged
// templates (no JSX) and is unaffected.
func bundleJSEntry(entry, outName string) (bundleStats, error) {
// Keep `debugger` statements in development so they can be hit when
// DevTools is attached; strip them in staging/production builds.
var drop esbuild.Drop
if appenv.Environment != appenv.EnvTypeDevelopment {
drop = esbuild.DropDebugger
}
vendorDir := filepath.Join(frontendDir, "vendor")
// Every bundled vendored package's entrypoint is declared in vendor.json rather
// than discovered from each package's `exports` — those mis-resolve (e.g.
// solid-js's bare core to its SSR build, dist/server.js, where createEffect/
// onMount are no-ops, so the DOM renders but every effect is silently dead).
entrypoints, err := loadVendorManifest(vendorDir)
if err != nil {
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
}
// The Go-native Solid compiler (Plugin) sits between the export shim and the
// vendor resolvers so it compiles .tsx/.jsx before they're resolved.
plugins := []esbuild.Plugin{defaultExportShimPlugin(), Plugin()}
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(vendorDir, entrypoints))
result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{entry},
Outfile: filepath.Join(outputDir, outName),
Bundle: true,
Write: true,
Format: esbuild.FormatESModule,
Target: esbuild.ES2022,
Sourcemap: esbuild.SourceMapLinked,
SourceRoot: "./",
Outbase: "frontend",
SourcesContent: esbuild.SourcesContentInclude,
MinifyWhitespace: true,
MinifyIdentifiers: true,
MinifySyntax: true,
Drop: drop,
Define: esbuildDefine(),
// Vendored packages live under frontend/vendor/<pkg> (npm-pack layout).
// NodePaths makes esbuild resolve bare imports there and bundle + tree-shake
// them like source (no node_modules). Bundling solid-js from a single vendored
// copy — rather than leaving it external for the import map — keeps ONE reactive
// runtime instance; a split instance silently breaks effect flushing (onMount
// never fires). Browser + development conditions pick each package's DOM dev
// build over any SSR entry its `module` field points at. Packages whose default
// entry pulls an un-bundled dep tree point their own package.json at a
// self-contained dist (e.g. pdf-lib's `browser` field) — config lives with the
// package, not here. vendorResolvePlugin marks bare imports NOT under
// frontend/vendor as external (import-map resolved), so the vendor set stays
// data-driven with no per-package list in this source.
Platform: esbuild.PlatformBrowser,
Conditions: []string{"development"},
NodePaths: []string{vendorDir},
// Assets emitted by assetURLPlugin (e.g. the pdfjs worker, which can't be
// inlined because it reads import.meta.url) land in wwwroot/vendor and are
// referenced by a root-absolute URL.
AssetNames: "vendor/[name]",
PublicPath: "/",
LogLevel: esbuild.LogLevelWarning,
Plugins: plugins,
})
if len(result.Errors) > 0 {
for _, e := range result.Errors {
loc := ""
if e.Location != nil {
loc = fmt.Sprintf("%s:%d:%d ", e.Location.File, e.Location.Line, e.Location.Column)
}
fmt.Fprintf(os.Stderr, " %s%s\n", loc, e.Text)
}
return bundleStats{}, fmt.Errorf("esbuild produced %d error(s)", len(result.Errors))
}
jsPath := filepath.Join(outputDir, outName)
if err := stripSourcemapParentPrefix(jsPath + ".map"); err != nil {
return bundleStats{}, fmt.Errorf("rewriting sourcemap paths: %w", err)
}
info, err := os.Stat(jsPath)
if err != nil {
return bundleStats{}, err
}
return bundleStats{files: countSources(result), bytes: int(info.Size())}, nil
}
// stripSourcemapParentPrefix rewrites the sourcemap's `sources` entries to
// drop leading `../` segments. esbuild writes paths relative to the output
// file's directory; since the bundle lives in wwwroot/ and the sources live
// in frontend/, every entry starts with `../frontend/`. Stripping the prefix
// yields project-rooted paths like `frontend/src/app.ts`.
func stripSourcemapParentPrefix(mapPath string) error {
data, err := os.ReadFile(mapPath)
if err != nil {
return err
}
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
return err
}
raw, ok := m["sources"]
if !ok {
return nil
}
var sources []string
if err := json.Unmarshal(raw, &sources); err != nil {
return err
}
for i, s := range sources {
for strings.HasPrefix(s, "../") {
s = s[3:]
}
sources[i] = s
}
newRaw, err := json.Marshal(sources)
if err != nil {
return err
}
m["sources"] = newRaw
out, err := json.Marshal(m)
if err != nil {
return err
}
return os.WriteFile(mapPath, out, 0644)
}
// countSources returns the number of input files that contributed to
// the bundle, derived from esbuild's metafile. When the metafile is
// not requested, falls back to the entry-count.
func countSources(result esbuild.BuildResult) int {
if result.Metafile == "" {
return 1
}
// Each " \"path\":" line in the inputs section counts as a file.
// Cheap heuristic — avoids pulling in encoding/json for a stat.
idx := strings.Index(result.Metafile, `"inputs":{`)
if idx < 0 {
return 1
}
end := strings.Index(result.Metafile[idx:], `},"outputs"`)
if end < 0 {
return 1
}
return strings.Count(result.Metafile[idx:idx+end], `":{`)
}

352
go/bundler/js/ssr/dom.js Normal file
View File

@@ -0,0 +1,352 @@
// Minimal server-side DOM for running solid-js/html + solid-js/web inside
// goja. It implements only the surface Solid's client runtime actually
// touches (enumerated from wwwroot/vendor/solid-js-web.js and
// solid-js-html.js): linked-list tree mutation, template.innerHTML/.content,
// element/text/comment creation, attributes, className/textContent, and
// no-op event wiring.
//
// The tree is the source of truth as a doubly-linked list (firstChild,
// nextSibling, ...) which is how the real DOM models it and what Solid's
// clone-walk assumes. childNodes is a derived snapshot array so Solid's
// `[...el.childNodes]` spreads work.
//
// HTML *parsing* (innerHTML setter) is the one genuinely hard operation, so
// it is delegated to Go via __parseHTML (x/net/html) which returns a JSON
// tree. Serialization back to a string is straightforward and lives here.
// -mta
(function (global) {
"use strict";
var ELEMENT_NODE = 1, TEXT_NODE = 3, COMMENT_NODE = 8, FRAGMENT_NODE = 11;
var VOID = {
area: 1, base: 1, br: 1, col: 1, embed: 1, hr: 1, img: 1, input: 1,
keygen: 1, link: 1, meta: 1, param: 1, source: 1, track: 1, wbr: 1,
};
var SVG_NS = "http://www.w3.org/2000/svg";
function escapeText(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeAttr(s) {
return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// ---- Node ----------------------------------------------------------
class Node {
constructor(type) {
this.nodeType = type;
this.parentNode = null;
this.firstChild = null;
this.lastChild = null;
this.previousSibling = null;
this.nextSibling = null;
this._$host = null;
this.host = null;
}
get childNodes() {
var out = [], n = this.firstChild;
while (n) { out.push(n); n = n.nextSibling; }
return out;
}
appendChild(child) {
detach(child);
child.parentNode = this;
child.previousSibling = this.lastChild;
child.nextSibling = null;
if (this.lastChild) this.lastChild.nextSibling = child;
else this.firstChild = child;
this.lastChild = child;
return child;
}
insertBefore(child, ref) {
if (ref == null) return this.appendChild(child);
if (ref.parentNode !== this) throw new Error("insertBefore: ref not a child");
detach(child);
child.parentNode = this;
child.nextSibling = ref;
child.previousSibling = ref.previousSibling;
if (ref.previousSibling) ref.previousSibling.nextSibling = child;
else this.firstChild = child;
ref.previousSibling = child;
return child;
}
removeChild(child) {
if (child.parentNode !== this) throw new Error("removeChild: not a child");
detach(child);
return child;
}
replaceChild(newNode, oldNode) {
this.insertBefore(newNode, oldNode);
this.removeChild(oldNode);
return oldNode;
}
remove() { detach(this); }
replaceWith() {
var args = Array.prototype.slice.call(arguments);
var parent = this.parentNode, ref = this.nextSibling;
if (!parent) return;
detach(this);
for (var i = 0; i < args.length; i++) {
var a = args[i];
if (typeof a === "string") a = new Text(a);
parent.insertBefore(a, ref);
}
}
cloneNode(deep) {
var copy = this._shallowClone();
if (deep) {
var n = this.firstChild;
while (n) { copy.appendChild(n.cloneNode(true)); n = n.nextSibling; }
}
return copy;
}
get textContent() {
if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) return this.data;
var out = "", n = this.firstChild;
while (n) {
if (n.nodeType !== COMMENT_NODE) out += n.textContent;
n = n.nextSibling;
}
return out;
}
set textContent(value) {
if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) { this.data = String(value); return; }
while (this.firstChild) this.removeChild(this.firstChild);
if (value !== "" && value != null) this.appendChild(new Text(String(value)));
}
querySelectorAll(sel) { return querySelectorAll(this, sel); }
querySelector(sel) { var r = querySelectorAll(this, sel); return r.length ? r[0] : null; }
}
function detach(node) {
var p = node.parentNode;
if (!p) return;
if (node.previousSibling) node.previousSibling.nextSibling = node.nextSibling;
else p.firstChild = node.nextSibling;
if (node.nextSibling) node.nextSibling.previousSibling = node.previousSibling;
else p.lastChild = node.previousSibling;
node.parentNode = null;
node.previousSibling = null;
node.nextSibling = null;
}
// ---- Text / Comment ------------------------------------------------
class Text extends Node {
constructor(data) { super(TEXT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#text"; }
_shallowClone() { return new Text(this.data); }
}
class Comment extends Node {
constructor(data) { super(COMMENT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#comment"; }
_shallowClone() { return new Comment(this.data); }
}
// ---- Element -------------------------------------------------------
class Element extends Node {
constructor(tagName, ns) {
super(ELEMENT_NODE);
this.tagName = tagName;
this.localName = String(tagName).toLowerCase();
this.nodeName = this.localName;
this.namespaceURI = ns || null;
this.attributes = {};
this._style = null;
this._classList = null;
if (this.localName === "template") this.content = new Fragment();
}
_shallowClone() {
var copy = new Element(this.tagName, this.namespaceURI);
for (var k in this.attributes) copy.attributes[k] = this.attributes[k];
if (this.content) {
var n = this.content.firstChild;
while (n) { copy.content.appendChild(n.cloneNode(true)); n = n.nextSibling; }
}
return copy;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
setAttributeNS(_ns, name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return name in this.attributes ? this.attributes[name] : null; }
hasAttribute(name) { return name in this.attributes; }
removeAttribute(name) { delete this.attributes[name]; }
removeAttributeNS(_ns, name) { delete this.attributes[name]; }
get className() { return this.attributes["class"] || ""; }
set className(v) { this.attributes["class"] = String(v); }
get id() { return this.attributes["id"] || ""; }
set id(v) { this.attributes["id"] = String(v); }
get innerHTML() { return serializeChildren(this); }
set innerHTML(htmlStr) {
var target = this.content ? this.content : this;
while (target.firstChild) target.removeChild(target.firstChild);
var json = global.__parseHTML(String(htmlStr));
var nodes = buildNodes(JSON.parse(json));
for (var i = 0; i < nodes.length; i++) target.appendChild(nodes[i]);
}
get style() {
if (!this._style) this._style = makeStyle(this);
return this._style;
}
get classList() {
if (!this._classList) this._classList = makeClassList(this);
return this._classList;
}
// Event wiring is irrelevant to server output.
addEventListener() {}
removeEventListener() {}
}
class Fragment extends Node {
constructor() { super(FRAGMENT_NODE); this.nodeName = "#document-fragment"; }
_shallowClone() { return new Fragment(); }
}
// ---- style / classList shims --------------------------------------
function makeStyle(el) {
return {
setProperty: function (k, v) {
var cur = parseStyle(el.attributes["style"] || "");
cur[k] = v;
el.attributes["style"] = stringifyStyle(cur);
},
removeProperty: function (k) {
var cur = parseStyle(el.attributes["style"] || "");
delete cur[k];
el.attributes["style"] = stringifyStyle(cur);
},
get cssText() { return el.attributes["style"] || ""; },
set cssText(v) { el.attributes["style"] = String(v); },
};
}
function parseStyle(s) {
var out = {};
s.split(";").forEach(function (decl) {
var i = decl.indexOf(":");
if (i > -1) out[decl.slice(0, i).trim()] = decl.slice(i + 1).trim();
});
return out;
}
function stringifyStyle(o) {
return Object.keys(o).map(function (k) { return k + ":" + o[k]; }).join(";");
}
function makeClassList(el) {
function read() { return (el.attributes["class"] || "").split(/\s+/).filter(Boolean); }
function write(list) { el.attributes["class"] = list.join(" "); }
return {
add: function () { var l = read(); for (var i = 0; i < arguments.length; i++) if (l.indexOf(arguments[i]) < 0) l.push(arguments[i]); write(l); },
remove: function () { var l = read(), a = Array.prototype.slice.call(arguments); write(l.filter(function (c) { return a.indexOf(c) < 0; })); },
toggle: function (c, force) { var l = read(), has = l.indexOf(c) > -1; if (force === undefined ? has : !force) write(l.filter(function (x) { return x !== c; })); else if (!has) { l.push(c); write(l); } },
contains: function (c) { return read().indexOf(c) > -1; },
};
}
// ---- build from parsed JSON ---------------------------------------
function buildNodes(arr) {
var out = [];
for (var i = 0; i < arr.length; i++) out.push(buildNode(arr[i]));
return out;
}
function buildNode(j) {
if (j.t === "t") return new Text(j.d);
if (j.t === "c") return new Comment(j.d);
var el = new Element(j.n, j.ns === "svg" ? SVG_NS : null);
if (j.a) for (var k in j.a) el.attributes[k] = j.a[k];
if (j.c) for (var i = 0; i < j.c.length; i++) el.appendChild(buildNode(j.c[i]));
return el;
}
// ---- serialization -------------------------------------------------
function serializeChildren(node) {
var out = "", n = node.firstChild;
while (n) { out += serializeNode(n); n = n.nextSibling; }
return out;
}
function serializeNode(node) {
if (node.nodeType === TEXT_NODE) return escapeText(node.data);
// "#" is solid-js/html's template insertion placeholder; any that
// survive instantiation are framework artifacts, not page content.
if (node.nodeType === COMMENT_NODE) return node.data === "#" ? "" : "<!--" + node.data + "-->";
if (node.nodeType === FRAGMENT_NODE) return serializeChildren(node);
// Output the original-case tag (SVG is case-sensitive: viewBox,
// linearGradient); use the lowercased localName only for lookups.
var tag = node.tagName, lname = node.localName;
var s = "<" + tag;
for (var k in node.attributes) s += " " + k + '="' + escapeAttr(node.attributes[k]) + '"';
s += ">";
if (VOID[lname]) return s;
if (lname === "template" && node.content) s += serializeChildren(node.content);
else s += serializeChildren(node);
return s + "</" + tag + ">";
}
// ---- minimal querySelectorAll (only script,style and *[data-hk]) ---
function querySelectorAll(root, sel) {
var wantHk = /\[data-hk\]/.test(sel);
var tags = sel.split(",").map(function (s) { return s.trim().replace(/\[.*\]/, "").replace("*", "").toLowerCase(); }).filter(Boolean);
var out = [];
(function walk(n) {
var c = n.firstChild;
while (c) {
if (c.nodeType === ELEMENT_NODE) {
if (wantHk && c.attributes["data-hk"] != null) out.push(c);
else if (tags.indexOf(c.localName) > -1) out.push(c);
walk(c);
}
c = c.nextSibling;
}
})(root.content || root);
return out;
}
// ---- document ------------------------------------------------------
var document = {
createElement: function (tag) { return new Element(tag, null); },
createElementNS: function (ns, tag) { return new Element(tag, ns); },
createTextNode: function (data) { return new Text(data); },
createComment: function (data) { return new Comment(data); },
createDocumentFragment: function () { return new Fragment(); },
importNode: function (node, deep) { return node.cloneNode(deep); },
addEventListener: function () {},
removeEventListener: function () {},
nodeType: 9,
};
global.document = document;
global.Node = Node;
global.Element = Element;
global.Text = Text;
global.Comment = Comment;
global.window = global;
// Serialize a node's children (innerHTML) — the Go side calls this to
// extract the rendered markup from the render root.
global.__serialize = function (node) { return serializeChildren(node); };
})(globalThis);

94
go/bundler/jsx.go Normal file
View File

@@ -0,0 +1,94 @@
// Solid JSX/TSX compilation (part of package bundler): compiles Solid JSX/TSX to
// optimized Solid dom-expressions output (template cloning + fine-grained
// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler
// lives in compile_solid.go (parser) and compile_solid_gen.go (codegen);
// segment.go supplies the top-level splitter it uses to find component
// declarations for solid-refresh instrumentation.
//
// Compile is the prod path (SSR + release bundles); CompileDev adds solid-refresh
// HMR instrumentation. Both are plain function calls — fast enough that the old
// per-declaration incremental caching is gone. A small in-memory cache dedups
// identical transforms within a process (e.g. a module served to several page
// loads under HMR); there is no on-disk cache and no compiler bootstrap.
package bundler
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"sync"
esbuild "github.com/evanw/esbuild/pkg/api"
)
// cache dedups transforms by content within a process, so a module unchanged
// across dev page reloads (or repeated in a build) isn't recompiled.
var cache = struct {
sync.RWMutex
m map[string]string
}{m: map[string]string{}}
// cacheKey keys a transform by filename + mode + source.
func cacheKey(src, filename string, dev bool) string {
h := sha256.New()
h.Write([]byte(filename))
h.Write([]byte{0})
if dev {
h.Write([]byte{1})
} else {
h.Write([]byte{0})
}
h.Write([]byte{0})
h.Write([]byte(src))
return hex.EncodeToString(h.Sum(nil))
}
// Compile compiles one JSX/TSX source to Solid dom-expressions output (prod: no
// HMR instrumentation). Results are cached by content for this process.
func Compile(src, filename string) (string, error) { return compileCached(src, filename, false) }
// CompileDev is Compile with solid-refresh HMR instrumentation — the dev module
// server's entry.
func CompileDev(src, filename string) (string, error) { return compileCached(src, filename, true) }
func compileCached(src, filename string, dev bool) (string, error) {
key := cacheKey(src, filename, dev)
cache.RLock()
if out, ok := cache.m[key]; ok {
cache.RUnlock()
return out, nil
}
cache.RUnlock()
out, err := compileSolidGo(src, filename, dev)
if err != nil {
return "", err
}
cache.Lock()
cache.m[key] = out
cache.Unlock()
return out, nil
}
// Plugin returns the esbuild OnLoad hook that Solid-compiles every .tsx/.jsx file
// in the bundle graph (prod path). Plain .ts/.js files are left to esbuild.
func Plugin() esbuild.Plugin {
return esbuild.Plugin{
Name: "solid-jsx",
Setup: func(b esbuild.PluginBuild) {
b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(tsx|jsx)$`}, func(a esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
data, err := os.ReadFile(a.Path)
if err != nil {
return esbuild.OnLoadResult{}, err
}
out, err := Compile(string(data), filepath.Base(a.Path))
if err != nil {
return esbuild.OnLoadResult{}, err
}
loader := esbuild.LoaderJS
return esbuild.OnLoadResult{Contents: &out, Loader: loader}, nil
})
},
}
}

View File

@@ -0,0 +1,43 @@
package bundler
import (
"fmt"
"os"
"path/filepath"
"testing"
)
func benchRead(b *testing.B, rel string) string {
b.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", rel))
if err != nil {
b.Skipf("cannot read %s: %v", rel, err)
}
return string(data)
}
var benchComponents = []string{
"ui/AutoTable.tsx", // ~212 KB
"ui/Forms.tsx", // ~75 KB
"pages/app/user/Profile.tsx", // ~26 KB
"pages/public/PublicLayout.tsx", // ~27 KB
}
// BenchmarkCompileDev times the full dev HMR compile (Babel solid + solid-refresh
// in goja) per component, busting the cache each iteration so every run is a real
// recompile — the cost the browser waits on for a single-file HMR update. This is
// the harness for judging whether a compile-path change actually moves the needle;
// as of this writing the goja/Babel transform dominates (seconds for large files).
func BenchmarkCompileDev(b *testing.B) {
for _, rel := range benchComponents {
src := benchRead(b, rel)
b.Run(rel, func(b *testing.B) {
for i := 0; i < b.N; i++ {
id := fmt.Sprintf("bench/c%d.tsx", i) // unique id -> cache miss
if _, err := CompileDev(src, id); err != nil {
b.Fatal(err)
}
}
})
}
}

View File

@@ -0,0 +1,48 @@
package bundler
import (
"strings"
"testing"
)
// CompileDev should Solid-compile the component AND wrap it with solid-refresh
// HMR instrumentation: an import from the solid-refresh runtime, a registry hot
// boundary, and an import.meta.hot accept (bundler:"esm"). None of these appear
// in the plain (prod) Compile output.
func TestCompileDevSolidRefresh(t *testing.T) {
src := `import { createSignal } from "solid-js";
export default function Counter() {
const [c, setC] = createSignal(0);
return <button onclick={() => setC(c() + 1)}>Count: {c()}</button>;
}
`
dev, err := CompileDev(src, "ui/Counter.tsx")
if err != nil {
t.Fatalf("CompileDev: %v", err)
}
t.Logf("DEV OUTPUT:\n%s", dev)
for _, want := range []string{
`solid-refresh`, // runtime import
"$$registry", // HMR registry boundary
"import.meta.hot", // esm bundler hot API
"_tmpl$", // still Solid-compiled
} {
if !strings.Contains(dev, want) {
t.Errorf("dev output missing %q", want)
}
}
// Prod compile of the same source must NOT carry refresh instrumentation,
// and (regression for the mode-keyed cache) must differ from the dev output.
prod, err := Compile(src, "ui/Counter.tsx")
if err != nil {
t.Fatalf("Compile: %v", err)
}
if strings.Contains(prod, "solid-refresh") || strings.Contains(prod, "import.meta.hot") {
t.Errorf("prod output leaked HMR instrumentation:\n%s", prod)
}
if prod == dev {
t.Errorf("mode-keyed cache broken: dev and prod output identical")
}
}

61
go/bundler/jsx_test.go Normal file
View File

@@ -0,0 +1,61 @@
package bundler
import (
"strings"
"testing"
)
// Compiling a Solid component should yield Solid's optimized dom output: a
// template clone, fine-grained insert, delegated events, and imports from
// solid-js/web — none of which a runtime factory would emit. TS types must be
// stripped too.
func TestCompileSolidComponent(t *testing.T) {
src := `import { createSignal } from "solid-js";
interface Props { start: number }
export function Counter(props: Props) {
const [c, setC] = createSignal<number>(props.start);
return <button class="btn" onclick={() => setC(c() + 1)}>Count: {c()}</button>;
}
`
out, err := Compile(src, "Counter.tsx")
if err != nil {
t.Fatalf("Compile: %v", err)
}
t.Logf("OUTPUT:\n%s", out)
for _, want := range []string{
`from "solid-js/web"`, // compiled helpers
"_tmpl$", // template clone
"template(", // template factory
"createSignal", // user code preserved
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q", want)
}
}
// TS type syntax must be gone.
for _, bad := range []string{"interface Props", ": Props", "<number>"} {
if strings.Contains(out, bad) {
t.Errorf("TS syntax leaked: %q", bad)
}
}
}
// A second call with identical input hits the cache and still returns the same
// compiled output.
func TestCompileCache(t *testing.T) {
src := `export function A() { return <div>hi</div>; }`
a, err := Compile(src, "A.tsx")
if err != nil {
t.Fatalf("first: %v", err)
}
b, err := Compile(src, "A.tsx")
if err != nil {
t.Fatalf("second: %v", err)
}
if a != b {
t.Errorf("cached output differs")
}
}

228
go/bundler/renderer.go Normal file
View File

@@ -0,0 +1,228 @@
package bundler
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// devPageRenderers caches one dev Renderer per public-page SSR entry so repeated
// requests reuse the cached HTML until frontend/src changes.
var devPageRenderers sync.Map // entry string -> *Renderer
// DevRenderPublicPage live-renders a public page's SSR body for the dev server,
// re-rendering only when frontend/src changes (Renderer's dev-mode mtime check).
// module is relative to frontend/src (e.g. "pages/public/AboutUs.tsx"), component
// is the exported body name, and currentPath drives PublicLayout's active nav.
// The server runs from the repo root, so projectRoot is ".".
func DevRenderPublicPage(module, component, currentPath string) (string, error) {
fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
entry := ssrEntrySolid(fullModule, component, currentPath)
r, _ := devPageRenderers.LoadOrStore(entry, NewRenderer(".", entry, true))
return r.(*Renderer).HTML()
}
// devPublicBundles caches one compiled SSR bundle per ISR entry, re-bundled only
// when frontend/src changes. Unlike devPageRenderers (which caches the finished
// data-free HTML), ISR renders with fresh data each request, so we cache the
// bundle and re-render it per request.
var devPublicBundles sync.Map // entry string -> *devBundle
type devBundle struct {
root, entry string
mu sync.Mutex
bundle string
builtAt time.Time
ok bool
}
func (b *devBundle) get() (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.ok && !newestSourceMtime(b.root).After(b.builtAt) {
return b.bundle, nil
}
bundled, err := BundleEntry(b.entry, b.root)
if err != nil {
return "", err
}
b.bundle, b.builtAt, b.ok = bundled, newestSourceMtime(b.root), true
return bundled, nil
}
// DevRenderPublicPageWithData live-renders an ISR public page in dev: it bundles
// the page from current source (re-bundling only when frontend/src changes) and
// renders it with the supplied server data. So editing an ISR page's component
// shows on the next reload even with JS disabled — only the Go data-loading code
// (the real server component) still needs a server restart.
func DevRenderPublicPageWithData(module, component, currentPath, dataJSON string) (string, error) {
fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
entry := ssrEntrySolid(fullModule, component, currentPath)
bv, _ := devPublicBundles.LoadOrStore(entry, &devBundle{root: ".", entry: entry})
bundle, err := bv.(*devBundle).get()
if err != nil {
return "", err
}
return RenderBundleWithData(bundle, dataJSON)
}
// RewarmDevPublicPages re-renders, in the background, every public page whose dev
// SSR has already been requested — so after an edit the fresh no-JS SSR is ready
// before the next reload instead of being rendered lazily on it. The dev watcher
// calls this on a source change. Each Renderer only actually re-renders if
// frontend/src changed, so redundant calls are cheap; errors are left for the
// request path to surface.
func RewarmDevPublicPages() {
devPageRenderers.Range(func(_, v any) bool {
go v.(*Renderer).HTML()
return true
})
devPublicBundles.Range(func(_, v any) bool {
go v.(*devBundle).get()
return true
})
}
// newestSourceMtime returns the newest modification time under
// projectRoot/frontend/src, or the zero time if the tree can't be walked
// (treated as "unchanged"). Drives the dev-mode rebuild checks.
func newestSourceMtime(projectRoot string) time.Time {
var newest time.Time
srcDir := filepath.Join(projectRoot, "frontend", "src")
filepath.WalkDir(srcDir, func(_ string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if info, e := d.Info(); e == nil && info.ModTime().After(newest) {
newest = info.ModTime()
}
return nil
})
return newest
}
// Renderer renders one public-page entry to HTML and caches the result.
// Because SSR output is data-free it is identical every request, so a cached
// string can be reused indefinitely. In dev the cache is invalidated when any
// file under frontend/src changes (a cheap mtime scan), so source edits show
// up on reload without a restart and without paying the ~1s bundle+render on
// every request.
type Renderer struct {
projectRoot string
entry string
dev bool
mu sync.Mutex
cached string
builtAt time.Time // newest source mtime seen at last build
ok bool
}
func NewRenderer(projectRoot, entry string, dev bool) *Renderer {
return &Renderer{projectRoot: projectRoot, entry: entry, dev: dev}
}
// HTML returns the rendered markup, rebuilding only when necessary.
func (r *Renderer) HTML() (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.ok {
if !r.dev {
return r.cached, nil
}
if !r.maxSourceMtime().After(r.builtAt) {
return r.cached, nil // sources unchanged since last build
}
}
out, err := renderOnce(r.entry, r.projectRoot)
if err != nil {
return "", err
}
r.cached, r.builtAt, r.ok = out, r.maxSourceMtime(), true
return out, nil
}
// maxSourceMtime returns the newest modification time under frontend/src.
func (r *Renderer) maxSourceMtime() time.Time { return newestSourceMtime(r.projectRoot) }
func renderOnce(entry, projectRoot string) (string, error) {
bundle, err := BundleEntry(entry, projectRoot)
if err != nil {
return "", err
}
eng, err := New()
if err != nil {
return "", err
}
if err := eng.LoadBundle(bundle); err != nil {
return "", err
}
return eng.Render()
}
// RenderBundleWithData runs a pre-bundled render entry (baked into
// public_pages.gen.go by RenderEntryFull) in goja with server data injected, and
// returns the rendered HTML. This is the runtime ISR path — no esbuild, no
// source files on disk, so it works in a single-binary release.
func RenderBundleWithData(bundleJS, dataJSON string) (string, error) {
eng, err := New()
if err != nil {
return "", err
}
if err := eng.SetServerData(dataJSON); err != nil {
return "", err
}
if err := eng.LoadBundle(bundleJS); err != nil {
return "", err
}
return eng.Render()
}
// RenderEntryFull bundles and renders one SSR entry, returning the rendered
// (data-free) skeleton HTML, the bundled JS, and the on-disk input files esbuild
// pulled in. The bundler (genssr.go) uses this at build time: it
// bakes the HTML for every page and, for dynamic (ISR) pages, also bakes the JS
// so the server can re-render it with data at request time (RenderBundleWithData)
// — no esbuild or source files on disk in production. The inputs feed
// build-time change detection.
func RenderEntryFull(entry, projectRoot string) (html, js string, inputs []string, err error) {
bundled, metafile, err := bundleEntry(entry, projectRoot, true)
if err != nil {
return "", "", nil, err
}
eng, err := New()
if err != nil {
return "", "", nil, err
}
if err := eng.LoadBundle(bundled); err != nil {
return "", "", nil, err
}
out, err := eng.Render()
if err != nil {
return "", "", nil, err
}
return out, bundled, metafileInputs(metafile), nil
}
// metafileInputs returns the existing-on-disk input files from an esbuild
// metafile (paths are relative to the build's working directory). Virtual
// inputs like the inline entry are skipped — they don't os.Stat.
func metafileInputs(metafile string) []string {
var mf struct {
Inputs map[string]json.RawMessage `json:"inputs"`
}
if metafile == "" || json.Unmarshal([]byte(metafile), &mf) != nil {
return nil
}
out := make([]string, 0, len(mf.Inputs))
for p := range mf.Inputs {
if _, err := os.Stat(p); err == nil {
out = append(out, filepath.ToSlash(p))
}
}
return out
}

293
go/bundler/segment.go Normal file
View File

@@ -0,0 +1,293 @@
package bundler
// Top-level source segmentation. segmentTopLevel splits a module into contiguous
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to
// find component declarations for solid-refresh instrumentation (wrapComponents
// in compile_solid_gen.go).
//
// Correctness contract: join(chunks) == src, always. A chunk always begins at a
// top-level declaration keyword and contains only whole top-level statements. The
// splitter is deliberately conservative — when the lexer is unsure it simply
// doesn't cut, producing fewer/larger chunks (still correct).
import "strings"
// declKeywords begin a top-level declaration. A line that starts (at brace depth
// 0, outside any string/comment/template/regex) with one of these — as a whole
// word — is a chunk boundary. `export` covers `export default`, `export const`,
// `export function`, and re-exports; `async` covers `async function`.
var declKeywords = []string{
"import", "export", "const", "let", "var", "function",
"async", "class", "type", "interface", "enum", "declare", "abstract",
}
// regexPrefixKeywords are the identifiers after which a `/` begins a regex
// literal rather than a division (e.g. `return /x/`), needed so the lexer keeps
// an accurate brace depth through regexes that contain braces or quotes.
var regexPrefixKeywords = map[string]bool{
"return": true, "typeof": true, "instanceof": true, "in": true, "of": true,
"new": true, "delete": true, "void": true, "do": true, "else": true,
"yield": true, "await": true, "case": true,
}
// segmentTopLevel splits src into chunks whose concatenation is exactly src.
// Returns a single chunk (the whole source) when there is nothing safe to split.
func segmentTopLevel(src string) []string {
cuts := topLevelCuts(src)
if len(cuts) <= 1 {
return []string{src}
}
chunks := make([]string, 0, len(cuts))
for i := range cuts {
end := len(src)
if i+1 < len(cuts) {
end = cuts[i+1]
}
chunks = append(chunks, src[cuts[i]:end])
}
// Defensive: the construction above is lossless, but never return a
// non-lossless split — a single chunk is always safe.
if strings.Join(chunks, "") != src {
return []string{src}
}
return chunks
}
// lexical states
const (
stNormal = iota
stLineComment
stBlockComment
stSingle // '...'
stDouble // "..."
stTemplate
stRegex
)
// topLevelCuts returns the sorted byte offsets at which chunks begin. Always
// includes 0. A cut is placed at the start of any line that begins in column 0
// (no leading whitespace) with a declaration keyword, provided the lexer is in a
// clean state there — i.e. not inside a block comment, template body, or `${}`
// interpolation that spans into this line.
//
// Column 0 is the top-level signal: these files indent everything inside a
// function/JSX, so a keyword in column 0 is a top-level declaration. That lets
// the lexer ignore brace depth and JSX entirely (JSX and nested statements are
// always indented) — it need only track string/comment/template state so a
// keyword *inside* a multi-line string or comment isn't mistaken for a boundary.
// Regexes and single/double strings can't span lines, so any mis-lex of them
// self-heals at the newline before the next candidate line.
func topLevelCuts(src string) []int {
cuts := []int{0}
n := len(src)
state := stNormal
depth := 0 // only tracked to match `${ ... }` interpolation braces
// tmplStack holds the interpolation brace depth captured at each `${` so the
// matching `}` resumes the template body instead of being counted as a plain
// brace. Non-empty ⇒ we're inside an interpolation (line not a clean start).
var tmplStack []int
var prevSig byte // last significant byte, for regex-vs-division
addCut := func(off int) {
if off > cuts[len(cuts)-1] {
cuts = append(cuts, off)
}
}
// A newline just moved us to lineStart; if the lexer is clean there and the
// line begins in column 0 with a declaration keyword, it's a chunk boundary.
checkCut := func(lineStart int) {
if state == stNormal && len(tmplStack) == 0 && startsDeclKeyword(src, lineStart) {
addCut(lineStart)
}
}
checkCut(0)
for i := 0; i < n; i++ {
c := src[i]
switch state {
case stNormal:
switch c {
case '/':
if i+1 < n && src[i+1] == '/' {
state = stLineComment
i++
continue
}
if i+1 < n && src[i+1] == '*' {
state = stBlockComment
i++
continue
}
if regexAllowed(src, i, prevSig) {
state = stRegex
prevSig = c
continue
}
prevSig = c
case '\'':
state = stSingle
prevSig = c
case '"':
state = stDouble
prevSig = c
case '`':
state = stTemplate
prevSig = c
case '{', '(', '[':
depth++
prevSig = c
case '}':
if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] {
tmplStack = tmplStack[:len(tmplStack)-1]
depth--
state = stTemplate
} else {
if depth > 0 {
depth--
}
prevSig = c
}
case ')', ']':
if depth > 0 {
depth--
}
prevSig = c
case '\n':
checkCut(i + 1)
case ' ', '\t', '\r':
// insignificant; leave prevSig
default:
prevSig = c
}
case stLineComment:
if c == '\n' {
state = stNormal
checkCut(i + 1)
}
case stBlockComment:
if c == '*' && i+1 < n && src[i+1] == '/' {
state = stNormal
i++
}
// a newline inside a block comment is not a clean start: no checkCut
case stSingle:
if c == '\\' {
i++
} else if c == '\'' {
state = stNormal
prevSig = c
} else if c == '\n' {
state = stNormal // strings can't span lines; recover
checkCut(i + 1)
}
case stDouble:
if c == '\\' {
i++
} else if c == '"' {
state = stNormal
prevSig = c
} else if c == '\n' {
state = stNormal
checkCut(i + 1)
}
case stTemplate:
if c == '\\' {
i++
} else if c == '`' {
state = stNormal
prevSig = c
} else if c == '$' && i+1 < n && src[i+1] == '{' {
depth++
tmplStack = append(tmplStack, depth)
state = stNormal
i++
}
// templates may span lines; the continuation is not a clean start
case stRegex:
if c == '\\' {
i++
} else if c == '[' {
for i++; i < n; i++ { // character class: skip to `]`
if src[i] == '\\' {
i++
continue
}
if src[i] == ']' {
break
}
}
} else if c == '/' {
state = stNormal
prevSig = c
} else if c == '\n' {
state = stNormal // regexes can't span lines; recover
checkCut(i + 1)
}
}
}
return cuts
}
// startsDeclKeyword reports whether src[i:] begins with a declaration keyword as
// a whole word (the next character is not part of an identifier).
func startsDeclKeyword(src string, i int) bool {
for _, kw := range declKeywords {
if strings.HasPrefix(src[i:], kw) {
j := i + len(kw)
if j >= len(src) || !isIdentPart(src[j]) {
return true
}
}
}
return false
}
// regexAllowed reports whether a `/` at position i begins a regex literal (as
// opposed to a division operator), from the preceding significant byte and, when
// that byte ends an identifier, whether the identifier is a regex-prefix keyword.
func regexAllowed(src string, i int, prevSig byte) bool {
if prevSig == 0 {
return true // start of input
}
if isIdentPart(prevSig) {
// value context (identifier/number) unless the word is a keyword like
// `return` after which a regex is expected.
word := trailingWord(src, i)
return regexPrefixKeywords[word]
}
switch prevSig {
case ')', ']', '}':
return false // end of a value/call/index
default:
// after operators, punctuation, `(`, `,`, `=`, etc. → regex expected
return true
}
}
// trailingWord returns the identifier word ending just before the run of
// whitespace that precedes position i (used to classify the token before a `/`).
func trailingWord(src string, i int) string {
j := i
for j > 0 && (src[j-1] == ' ' || src[j-1] == '\t' || src[j-1] == '\r' || src[j-1] == '\n') {
j--
}
end := j
for j > 0 && isIdentPart(src[j-1]) {
j--
}
return src[j:end]
}
func isIdentPart(b byte) bool {
return b == '_' || b == '$' ||
(b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}

108
go/bundler/segment_test.go Normal file
View File

@@ -0,0 +1,108 @@
package bundler
import (
"os"
"path/filepath"
"strings"
"testing"
)
// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the
// package dir, which is internal/bundler).
func walkRepoTSX(t *testing.T) []string {
t.Helper()
root := filepath.Join("..", "..", "frontend", "src")
var files []string
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
switch filepath.Ext(p) {
case ".tsx", ".jsx":
files = append(files, p)
}
return nil
})
if err != nil {
t.Skipf("cannot walk frontend/src: %v", err)
}
if len(files) == 0 {
t.Skip("no .tsx files found")
}
return files
}
// Segmentation must be lossless for every real component in the repo, and the
// join invariant must hold exactly (byte-for-byte).
func TestSegmentLosslessRepo(t *testing.T) {
files := walkRepoTSX(t)
totalChunks, multiChunkFiles := 0, 0
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
src := string(data)
chunks := segmentTopLevel(src)
if strings.Join(chunks, "") != src {
t.Errorf("%s: segmentation not lossless (%d chunks)", f, len(chunks))
continue
}
totalChunks += len(chunks)
if len(chunks) > 1 {
multiChunkFiles++
}
// Every chunk (except possibly the first) must begin at a declaration
// keyword after optional leading blank lines/comments were attached to
// the previous chunk — i.e. its first non-space line starts with a kw.
}
t.Logf("segmented %d files: %d split into >1 chunk, %d chunks total (avg %.1f)",
len(files), multiChunkFiles, totalChunks, float64(totalChunks)/float64(len(files)))
}
// Focused check on the pain file: it should split into many chunks so that
// editing any single declaration recompiles only that declaration.
func TestSegmentAutoTable(t *testing.T) {
p := filepath.Join("..", "..", "frontend", "src", "ui", "AutoTable.tsx")
data, err := os.ReadFile(p)
if err != nil {
t.Skipf("cannot read AutoTable: %v", err)
}
chunks := segmentTopLevel(string(data))
if strings.Join(chunks, "") != string(data) {
t.Fatal("AutoTable segmentation not lossless")
}
// Size distribution: how big is the largest chunk (the residual worst case)?
maxLen, maxIdx := 0, 0
for i, c := range chunks {
if len(c) > maxLen {
maxLen, maxIdx = len(c), i
}
}
head := strings.TrimSpace(chunks[maxIdx])
if len(head) > 80 {
head = head[:80]
}
t.Logf("AutoTable: %d chunks, largest = %d bytes (%.0f%% of file), starts: %q",
len(chunks), maxLen, 100*float64(maxLen)/float64(len(data)), head)
if len(chunks) < 20 {
t.Errorf("expected AutoTable to split into many chunks, got %d", len(chunks))
}
}
// A few hand-written cases exercise the lexer edge cases the repo may not cover.
func TestSegmentEdgeCases(t *testing.T) {
cases := map[string]string{
"template with braces and decl-looking text": "const a = `x${ {y:1} }z\nconst notReal = 2`;\nexport const b = 3;\n",
"regex with braces": "const re = /[{}]/g;\nfunction f() { return /a{2}/; }\nexport function g() {}\n",
"block comment spanning decl keyword": "/*\nconst hidden = 1;\nfunction alsoHidden() {}\n*/\nexport const real = 1;\n",
"string with keyword": "const s = \"export function fake() {}\";\nfunction real() {}\n",
"nested template": "const t = `a${`b${1}c`}d`;\nexport const u = 1;\n",
}
for name, src := range cases {
chunks := segmentTopLevel(src)
if got := strings.Join(chunks, ""); got != src {
t.Errorf("%s: not lossless\n src=%q\n got=%q", name, src, got)
}
}
}

340
go/bundler/ssr.go Normal file
View File

@@ -0,0 +1,340 @@
// 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
}

145
go/bundler/ssr_test.go Normal file
View File

@@ -0,0 +1,145 @@
package bundler
import (
"path/filepath"
"strings"
"testing"
"time"
)
// entryHome renders the real home-page component the way ssrEntrySolid does:
// plain JS (createComponent, no JSX in the entry) importing the .tsx page, which
// the Go Solid compiler compiles. PublicLayout is omitted to target the body.
const entryHome = `
import { render, createComponent } from "solid-js/web";
import { Home } from "./frontend/src/pages/public/Home.tsx";
globalThis.__render = function () {
const root = document.createElement("div");
const dispose = render(function () { return createComponent(Home, {}); }, root);
const out = globalThis.__serialize(root);
dispose();
return out;
};
`
// Proves the ISR data-injection path the server uses: a pre-bundled render entry
// run in goja with server data injected (RenderBundleWithData) makes Home render
// the injected rates (via serverData()) instead of the loading skeleton.
func TestRenderHomeWithServerData(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatalf("abs project root: %v", err)
}
t.Chdir(root)
data := `{"rates":[{"term":"90 Day","low":"1.111%","high":"2.222%","avg":"1.500%"}]}`
js, err := BundleEntry(entryHome, ".")
if err != nil {
t.Fatalf("bundle: %v", err)
}
out, err := RenderBundleWithData(js, data)
if err != nil {
t.Fatalf("render with data: %v", err)
}
for _, want := range []string{"1.111%", "2.222%", "1.500%"} {
if !strings.Contains(out, want) {
t.Errorf("output missing injected rate %q", want)
}
}
// With data present the table shows it, not the loading skeleton.
if strings.Contains(out, "animate-pulse") {
t.Errorf("skeleton rendered despite injected data")
}
}
// Reproduces the server's setup (cwd at repo root, relative "." project root).
// Guards the esbuild alias-resolution bug where a non-absolute root yields
// bare-specifier alias targets that fail to resolve.
func TestRenderWithRelativeRoot(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatalf("abs project root: %v", err)
}
t.Chdir(root)
r := NewRenderer(".", entryHome, true)
out, err := r.HTML()
if err != nil {
t.Fatalf("render with relative root: %v", err)
}
if !strings.Contains(out, `class="page-home"`) {
t.Errorf("output missing page-home")
}
}
// Confirms the cache: the first HTML() pays bundle+render, the second (no source
// change) returns the cached string near-instantly.
func TestRendererCaching(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatalf("abs project root: %v", err)
}
r := NewRenderer(root, entryHome, false) // prod: warm HTML() returns the cached string directly
t0 := time.Now()
first, err := r.HTML()
if err != nil {
t.Fatalf("cold render: %v", err)
}
cold := time.Since(t0)
t1 := time.Now()
second, err := r.HTML()
if err != nil {
t.Fatalf("warm render: %v", err)
}
warm := time.Since(t1)
t.Logf("cold=%v warm=%v", cold, warm)
if first != second {
t.Errorf("cached output differs from first render")
}
if warm > cold/4 {
t.Errorf("cache hit too slow: cold=%v warm=%v", cold, warm)
}
}
// No data → the home page renders its loading skeleton, exercising compiled Solid
// against inline SVGs (viewBox case), string style attributes, entities, and the
// table. SVG attribute case and the style string must survive serialization.
func TestRenderHomeSkeleton(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatalf("abs project root: %v", err)
}
bundle, err := BundleEntry(entryHome, root)
if err != nil {
t.Fatalf("bundle: %v", err)
}
eng, err := New()
if err != nil {
t.Fatalf("engine: %v", err)
}
if err := eng.LoadBundle(bundle); err != nil {
t.Fatalf("load bundle: %v", err)
}
out, err := eng.Render()
if err != nil {
t.Fatalf("render: %v", err)
}
t.Logf("HOME SKELETON (%d bytes):\n%s", len(out), out)
for _, want := range []string{
`class="page-home"`,
`The Ultimate Funding and Investing Solution`,
`viewBox="0 0 24 24"`, // SVG attribute case preserved
`background-image: url(/images/public/hero-bg.jpg)`, // static string style baked into the template
`animate-pulse`, // skeleton bars (SSR forces showSkeleton)
`90 Day`, // term labels shown in the skeleton
`Schedule a free demo`,
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q", want)
}
}
}

192
go/bundler/ssrcache.go Normal file
View File

@@ -0,0 +1,192 @@
package bundler
// Build-time caching + parallelism for public-page SSR (see genssr.go).
//
// Rendering a page means bundling it with esbuild and running it through a goja
// runtime — too slow to repeat for every page on every build when nothing
// changed. So each render records the source files esbuild pulled in (from the
// metafile) and their content hashes; a later build reuses the cached HTML when
// the page's entry and every input file are byte-identical. Cache misses are
// rendered concurrently, one goja runtime per worker.
//
// The cache lives under tmp/ (gitignored) and is purely an optimization: any
// read/parse/write error just falls back to rendering.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
)
// ssrCachePath is the on-disk render cache (gitignored via tmp/).
var ssrCachePath = filepath.Join("tmp", "ssr-cache.json")
// ssrCacheEntry fingerprints one rendered page.
type ssrCacheEntry struct {
EntryHash string `json:"entry"` // hash of the goja entry source
Inputs map[string]string `json:"inputs"` // input file path -> content hash
HTML string `json:"html"` // the rendered, baked body
RenderJS string `json:"renderJS,omitempty"` // bundled JS, baked for dynamic (ISR) pages
}
// ssrCache is the whole render cache, keyed by page URL path.
type ssrCache struct {
Engine string `json:"engine"` // EngineHash() at write time
Pages map[string]ssrCacheEntry `json:"pages"`
}
func loadSSRCache() ssrCache {
data, err := os.ReadFile(ssrCachePath)
if err != nil {
return ssrCache{}
}
var c ssrCache
if json.Unmarshal(data, &c) != nil || c.Pages == nil {
return ssrCache{}
}
return c
}
func saveSSRCache(c ssrCache) {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(ssrCachePath), 0o755); err != nil {
return
}
_ = os.WriteFile(ssrCachePath, data, 0o644)
}
func hashString(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// fileHasher memoizes content hashes within a single build so files shared by
// several pages (the vendored Solid runtime, PublicLayout, shared UI) are read
// and hashed once. Safe for concurrent use.
type fileHasher struct {
mu sync.Mutex
m map[string]string // path -> hex hash; "" records a read failure
}
func newFileHasher() *fileHasher { return &fileHasher{m: map[string]string{}} }
// hash returns the file's content hash and whether it was readable.
func (f *fileHasher) hash(path string) (string, bool) {
f.mu.Lock()
if h, ok := f.m[path]; ok {
f.mu.Unlock()
return h, h != ""
}
f.mu.Unlock()
h := ""
if data, err := os.ReadFile(path); err == nil {
sum := sha256.Sum256(data)
h = hex.EncodeToString(sum[:])
}
f.mu.Lock()
f.m[path] = h
f.mu.Unlock()
return h, h != ""
}
// inputsUnchanged reports whether every recorded input still hashes the same.
// A page can't gain a new dependency without editing one of these files, so an
// all-match means the bundle (and thus the rendered output) is identical.
func inputsUnchanged(old map[string]string, h *fileHasher) bool {
if len(old) == 0 {
return false
}
for path, want := range old {
got, ok := h.hash(path)
if !ok || got != want {
return false
}
}
return true
}
func hashInputs(paths []string, h *fileHasher) map[string]string {
m := make(map[string]string, len(paths))
for _, p := range paths {
if sum, ok := h.hash(p); ok {
m[p] = sum
}
}
return m
}
// renderJob / renderResult carry a cache-miss page through the worker pool.
type renderJob struct {
idx int
path string
component string
entry string
}
type renderResult struct {
idx int
path string
html string
js string // bundled render entry (baked for dynamic/ISR pages)
inputs []string
}
// renderMisses renders the given jobs concurrently — one goja runtime per
// worker, capped at NumCPU — and returns their results in input order. On the
// first render error it stops reporting and returns that error.
func renderMisses(jobs []renderJob) ([]renderResult, error) {
if len(jobs) == 0 {
return nil, nil
}
limit := runtime.NumCPU()
if limit < 1 {
limit = 1
}
if limit > len(jobs) {
limit = len(jobs)
}
results := make([]renderResult, len(jobs))
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
for k := range jobs {
wg.Add(1)
go func(k int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
j := jobs[k]
html, js, inputs, err := RenderEntryFull(j.entry, ".")
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("rendering %s (%s): %w", j.path, j.component, err)
}
mu.Unlock()
return
}
results[k] = renderResult{idx: j.idx, path: j.path, html: html, js: js, inputs: inputs}
}(k)
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
return results, nil
}

9586
go/bundler/tailwind.go Normal file

File diff suppressed because it is too large Load Diff

217
go/bundler/tailwind_test.go Normal file
View File

@@ -0,0 +1,217 @@
package bundler
import (
"strings"
"testing"
)
func twTestCompile(t *testing.T, candidates ...string) string {
t.Helper()
css, _, err := twCompile(`@import "tailwindcss";`, ".", candidates)
if err != nil {
t.Fatalf("twCompile error: %v", err)
}
return css
}
// Edge cases that the modifier/fraction rewrite fixes.
func TestEngineEdgeCases(t *testing.T) {
cases := []struct {
candidate string
contains string
}{
// Improper fraction read as a fraction (not an opacity modifier).
{"aspect-16/9", "aspect-ratio: 16/9"},
// Proper fraction.
{"w-1/2", "width: calc(1 / 2 * 100%)"},
// Arbitrary opacity modifier decoded (not "[0.5]%").
{"bg-white/[0.5]", "color-mix(in oklab, var(--color-white) 50%, transparent)"},
// Bare spacing.
{"m-4", "margin: calc(var(--spacing) * 4)"},
{"p-4", "padding: calc(var(--spacing) * 4)"},
// text-{size}/{leading}: font-size AND line-height (the dropped-modifier fix).
{"text-sm/6", "line-height: calc(var(--spacing) * 6)"},
// Theme color via @theme namespace.
{"bg-red-500", "background-color: var(--color-red-500)"},
}
for _, c := range cases {
css := twTestCompile(t, c.candidate)
if !strings.Contains(css, c.contains) {
t.Errorf("compile(%q): expected to contain %q\n---\n%s", c.candidate, c.contains, css)
}
}
}
func TestSegmentTopLevel(t *testing.T) {
cases := []struct {
in string
sep string
want []string
}{
{"a:b:c", ":", []string{"a", "b", "c"}},
{"var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)", ",",
[]string{"var(--a, 0 0 1px rgb(0, 0, 0))", " 0 0 1px rgb(0, 0, 0)"}},
{"display:grid", ":", []string{"display", "grid"}},
{"[display:grid]", ":", []string{"[display:grid]"}},
{"red-500/50", "/", []string{"red-500", "50"}},
{"calc(1/2)/3", "/", []string{"calc(1/2)", "3"}},
}
for _, c := range cases {
got := segment(c.in, c.sep)
if len(got) != len(c.want) {
t.Errorf("segment(%q,%q) = %v, want %v", c.in, c.sep, got, c.want)
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("segment(%q,%q)[%d] = %q, want %q", c.in, c.sep, i, got[i], c.want[i])
}
}
}
}
func TestEscapeIdentifier(t *testing.T) {
cases := []struct{ in, want string }{
{"flex", "flex"},
{"hover:bg-red-500", `hover\:bg-red-500`},
{"w-1/2", `w-1\/2`},
{"bg-[#fff]", `bg-\[\#fff\]`},
{"2xl", `\32 xl`},
}
for _, c := range cases {
if got := escape(c.in); got != c.want {
t.Errorf("escape(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestDecodeArbitraryValue(t *testing.T) {
cases := []struct{ in, want string }{
{"100%_!important", "100% !important"},
{"calc(100dvh_-_5rem)", "calc(100dvh - 5rem)"},
{`url(/a_b.png)`, "url(/a_b.png)"},
{`var(--my_var)`, "var(--my_var)"},
{"calc(var(--spacing)*4_+_env(safe-area-inset-bottom,0px))",
"calc(var(--spacing) * 4 + env(safe-area-inset-bottom,0px))"},
}
for _, c := range cases {
if got := decodeArbitraryValue(c.in); got != c.want {
t.Errorf("decodeArbitraryValue(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestInferDataType(t *testing.T) {
cases := []struct {
value string
types []string
want string
}{
{"#ff0000", []string{dtColor, dtLength}, dtColor},
{"10rem", []string{dtColor, dtLength}, dtLength},
{"50%", []string{dtLength, dtPercentage}, dtPercentage},
{"16/9", []string{dtRatio, dtColor}, dtRatio},
{"var(--x)", []string{dtColor, dtLength}, ""},
{"calc(1px+2px)", []string{dtLength}, dtLength},
{"red", []string{dtColor}, dtColor},
}
for _, c := range cases {
if got := inferDataType(c.value, c.types); got != c.want {
t.Errorf("inferDataType(%q,%v) = %q, want %q", c.value, c.types, got, c.want)
}
}
}
func TestNumericPredicates(t *testing.T) {
if !isValidSpacingMultiplier("0.5") {
t.Error("0.5 should be a valid spacing multiplier")
}
if isValidSpacingMultiplier("0.3") {
t.Error("0.3 should NOT be a valid spacing multiplier")
}
if !isPositiveInteger("3") || isPositiveInteger("3.0") || isPositiveInteger("03") {
t.Error("isPositiveInteger canonical-form check failed")
}
}
// Apostrophes inside JS comments (e.g. "don't", "button's") must not desync the
// quote-based class scanner. Before comments were skipped, a stray apostrophe
// flipped quote parity and swallowed every class literal until the next quote —
// silently dropping singly-used utilities like the lineup card's switch and
// drag styles.
func TestExtractCandidatesSkipsComments(t *testing.T) {
src := "// we don't need reactivity here\n" +
"const cur = locked ? \"cursor-not-allowed\" : \"cursor-move\";\n" +
"/* the button's thumb: it's offset */\n" +
"const thumb = on ? \"translate-x-3\" : \"\";\n" +
"const tpl = `<input class=\"sr-only\"/>`;\n"
got := map[string]bool{}
for _, c := range extractCandidates(src) {
got[c] = true
}
for _, want := range []string{"cursor-not-allowed", "cursor-move", "translate-x-3", "sr-only"} {
if !got[want] {
t.Errorf("candidate %q was not extracted past comment apostrophes", want)
}
}
}
// scan returns the set of candidates extracted from src.
func scan(src string) map[string]bool {
got := map[string]bool{}
for _, c := range extractCandidates(src) {
got[c] = true
}
return got
}
// Quotes inside a regex literal must not be read as a string (which would
// desync the scanner and drop following class literals), and a division `/`
// must not be mistaken for a regex (which would swallow the code after it).
func TestExtractCandidatesRegexLiterals(t *testing.T) {
cases := []struct {
name string
src string
want []string
}{
{
"apostrophe in regex",
`const r = /it's/; const c = "cursor-move";`,
[]string{"cursor-move"},
},
{
"regex after return keyword",
`function f(){ return /a"b/; } const c = "p-4";`,
[]string{"p-4"},
},
{
"char class containing slash and quote",
`const r = /[/']/g; const c = "block";`,
[]string{"block"},
},
{
"division is not a regex (string between divisions survives)",
`const w = a / 2; cls = "text-lg"; const h = b / 4; cls2 = "m-2";`,
[]string{"text-lg", "m-2"},
},
{
"regex inside a template interpolation, class after it",
"const t = html`<a class=${x.replace(/'/g, \"\")}>${y ? \"p-5\" : \"p-6\"}</a>`;",
[]string{"p-5", "p-6"},
},
{
"regex with braces in template interpolation keeps ${} balanced",
"const t = html`<a class=${s.match(/[{}]/) ? \"flex\" : \"hidden\"}></a>`;",
[]string{"flex", "hidden"},
},
}
for _, tc := range cases {
got := scan(tc.src)
for _, w := range tc.want {
if !got[w] {
t.Errorf("%s: candidate %q not extracted (src=%q)", tc.name, w, tc.src)
}
}
}
}

393
go/bundler/tw_preflight.css Normal file
View File

@@ -0,0 +1,393 @@
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Remove default margins and padding
3. Reset all borders.
*/
*,
::after,
::before,
::backdrop,
::file-selector-button {
box-sizing: border-box; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 2 */
border: 0 solid; /* 3 */
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS.
*/
html,
:host {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
tab-size: 4; /* 3 */
font-family: --theme(
--default-font-family,
ui-sans-serif,
system-ui,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji'
); /* 4 */
font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */
font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */
-webkit-tap-highlight-color: transparent; /* 7 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Reset the default border style to a 1px solid border.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: --theme(
--default-mono-font-family,
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
'Liberation Mono',
'Courier New',
monospace
); /* 1 */
font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */
font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */
font-size: 1em; /* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Make lists unstyled by default.
*/
ol,
ul,
menu {
list-style: none;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/*
1. Inherit font styles in all browsers.
2. Remove border radius in all browsers.
3. Remove background color in all browsers.
4. Ensure consistent opacity for disabled states in all browsers.
*/
button,
input,
select,
optgroup,
textarea,
::file-selector-button {
font: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
letter-spacing: inherit; /* 1 */
color: inherit; /* 1 */
border-radius: 0; /* 2 */
background-color: transparent; /* 3 */
opacity: 1; /* 4 */
}
/*
Restore default font weight.
*/
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
/*
Restore indentation.
*/
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
/*
Restore space after button.
*/
::file-selector-button {
margin-inline-end: 4px;
}
/*
Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
*/
::placeholder {
opacity: 1;
}
/*
Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not
crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194)
*/
@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or
(contain-intrinsic-size: 1px) /* Safari 17+ */ {
::placeholder {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Ensure date/time inputs have the same height when empty in iOS Safari.
2. Ensure text alignment can be changed on date/time inputs in iOS Safari.
*/
::-webkit-date-and-time-value {
min-height: 1lh; /* 1 */
text-align: inherit; /* 2 */
}
/*
Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.
*/
::-webkit-datetime-edit {
display: inline-flex;
}
/*
Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.
*/
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit,
::-webkit-datetime-edit-year-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-minute-field,
::-webkit-datetime-edit-second-field,
::-webkit-datetime-edit-millisecond-field,
::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
/*
Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499)
*/
::-webkit-calendar-picker-indicator {
line-height: 1;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Correct the inability to style the border radius in iOS Safari.
*/
button,
input:where([type='button'], [type='reset'], [type='submit']),
::file-selector-button {
appearance: button;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
Make elements with the HTML hidden attribute stay hidden by default.
*/
[hidden]:where(:not([hidden='until-found'])) {
display: none !important;
}

510
go/bundler/tw_theme.css Normal file
View File

@@ -0,0 +1,510 @@
@theme default {
--font-sans:
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-red-50: oklch(97.1% 0.013 17.38);
--color-red-100: oklch(93.6% 0.032 17.717);
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-300: oklch(80.8% 0.114 19.571);
--color-red-400: oklch(70.4% 0.191 22.216);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-red-700: oklch(50.5% 0.213 27.518);
--color-red-800: oklch(44.4% 0.177 26.899);
--color-red-900: oklch(39.6% 0.141 25.723);
--color-red-950: oklch(25.8% 0.092 26.042);
--color-orange-50: oklch(98% 0.016 73.684);
--color-orange-100: oklch(95.4% 0.038 75.164);
--color-orange-200: oklch(90.1% 0.076 70.697);
--color-orange-300: oklch(83.7% 0.128 66.29);
--color-orange-400: oklch(75% 0.183 55.934);
--color-orange-500: oklch(70.5% 0.213 47.604);
--color-orange-600: oklch(64.6% 0.222 41.116);
--color-orange-700: oklch(55.3% 0.195 38.402);
--color-orange-800: oklch(47% 0.157 37.304);
--color-orange-900: oklch(40.8% 0.123 38.172);
--color-orange-950: oklch(26.6% 0.079 36.259);
--color-amber-50: oklch(98.7% 0.022 95.277);
--color-amber-100: oklch(96.2% 0.059 95.617);
--color-amber-200: oklch(92.4% 0.12 95.746);
--color-amber-300: oklch(87.9% 0.169 91.605);
--color-amber-400: oklch(82.8% 0.189 84.429);
--color-amber-500: oklch(76.9% 0.188 70.08);
--color-amber-600: oklch(66.6% 0.179 58.318);
--color-amber-700: oklch(55.5% 0.163 48.998);
--color-amber-800: oklch(47.3% 0.137 46.201);
--color-amber-900: oklch(41.4% 0.112 45.904);
--color-amber-950: oklch(27.9% 0.077 45.635);
--color-yellow-50: oklch(98.7% 0.026 102.212);
--color-yellow-100: oklch(97.3% 0.071 103.193);
--color-yellow-200: oklch(94.5% 0.129 101.54);
--color-yellow-300: oklch(90.5% 0.182 98.111);
--color-yellow-400: oklch(85.2% 0.199 91.936);
--color-yellow-500: oklch(79.5% 0.184 86.047);
--color-yellow-600: oklch(68.1% 0.162 75.834);
--color-yellow-700: oklch(55.4% 0.135 66.442);
--color-yellow-800: oklch(47.6% 0.114 61.907);
--color-yellow-900: oklch(42.1% 0.095 57.708);
--color-yellow-950: oklch(28.6% 0.066 53.813);
--color-lime-50: oklch(98.6% 0.031 120.757);
--color-lime-100: oklch(96.7% 0.067 122.328);
--color-lime-200: oklch(93.8% 0.127 124.321);
--color-lime-300: oklch(89.7% 0.196 126.665);
--color-lime-400: oklch(84.1% 0.238 128.85);
--color-lime-500: oklch(76.8% 0.233 130.85);
--color-lime-600: oklch(64.8% 0.2 131.684);
--color-lime-700: oklch(53.2% 0.157 131.589);
--color-lime-800: oklch(45.3% 0.124 130.933);
--color-lime-900: oklch(40.5% 0.101 131.063);
--color-lime-950: oklch(27.4% 0.072 132.109);
--color-green-50: oklch(98.2% 0.018 155.826);
--color-green-100: oklch(96.2% 0.044 156.743);
--color-green-200: oklch(92.5% 0.084 155.995);
--color-green-300: oklch(87.1% 0.15 154.449);
--color-green-400: oklch(79.2% 0.209 151.711);
--color-green-500: oklch(72.3% 0.219 149.579);
--color-green-600: oklch(62.7% 0.194 149.214);
--color-green-700: oklch(52.7% 0.154 150.069);
--color-green-800: oklch(44.8% 0.119 151.328);
--color-green-900: oklch(39.3% 0.095 152.535);
--color-green-950: oklch(26.6% 0.065 152.934);
--color-emerald-50: oklch(97.9% 0.021 166.113);
--color-emerald-100: oklch(95% 0.052 163.051);
--color-emerald-200: oklch(90.5% 0.093 164.15);
--color-emerald-300: oklch(84.5% 0.143 164.978);
--color-emerald-400: oklch(76.5% 0.177 163.223);
--color-emerald-500: oklch(69.6% 0.17 162.48);
--color-emerald-600: oklch(59.6% 0.145 163.225);
--color-emerald-700: oklch(50.8% 0.118 165.612);
--color-emerald-800: oklch(43.2% 0.095 166.913);
--color-emerald-900: oklch(37.8% 0.077 168.94);
--color-emerald-950: oklch(26.2% 0.051 172.552);
--color-teal-50: oklch(98.4% 0.014 180.72);
--color-teal-100: oklch(95.3% 0.051 180.801);
--color-teal-200: oklch(91% 0.096 180.426);
--color-teal-300: oklch(85.5% 0.138 181.071);
--color-teal-400: oklch(77.7% 0.152 181.912);
--color-teal-500: oklch(70.4% 0.14 182.503);
--color-teal-600: oklch(60% 0.118 184.704);
--color-teal-700: oklch(51.1% 0.096 186.391);
--color-teal-800: oklch(43.7% 0.078 188.216);
--color-teal-900: oklch(38.6% 0.063 188.416);
--color-teal-950: oklch(27.7% 0.046 192.524);
--color-cyan-50: oklch(98.4% 0.019 200.873);
--color-cyan-100: oklch(95.6% 0.045 203.388);
--color-cyan-200: oklch(91.7% 0.08 205.041);
--color-cyan-300: oklch(86.5% 0.127 207.078);
--color-cyan-400: oklch(78.9% 0.154 211.53);
--color-cyan-500: oklch(71.5% 0.143 215.221);
--color-cyan-600: oklch(60.9% 0.126 221.723);
--color-cyan-700: oklch(52% 0.105 223.128);
--color-cyan-800: oklch(45% 0.085 224.283);
--color-cyan-900: oklch(39.8% 0.07 227.392);
--color-cyan-950: oklch(30.2% 0.056 229.695);
--color-sky-50: oklch(97.7% 0.013 236.62);
--color-sky-100: oklch(95.1% 0.026 236.824);
--color-sky-200: oklch(90.1% 0.058 230.902);
--color-sky-300: oklch(82.8% 0.111 230.318);
--color-sky-400: oklch(74.6% 0.16 232.661);
--color-sky-500: oklch(68.5% 0.169 237.323);
--color-sky-600: oklch(58.8% 0.158 241.966);
--color-sky-700: oklch(50% 0.134 242.749);
--color-sky-800: oklch(44.3% 0.11 240.79);
--color-sky-900: oklch(39.1% 0.09 240.876);
--color-sky-950: oklch(29.3% 0.066 243.157);
--color-blue-50: oklch(97% 0.014 254.604);
--color-blue-100: oklch(93.2% 0.032 255.585);
--color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-blue-800: oklch(42.4% 0.199 265.638);
--color-blue-900: oklch(37.9% 0.146 265.522);
--color-blue-950: oklch(28.2% 0.091 267.935);
--color-indigo-50: oklch(96.2% 0.018 272.314);
--color-indigo-100: oklch(93% 0.034 272.788);
--color-indigo-200: oklch(87% 0.065 274.039);
--color-indigo-300: oklch(78.5% 0.115 274.713);
--color-indigo-400: oklch(67.3% 0.182 276.935);
--color-indigo-500: oklch(58.5% 0.233 277.117);
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-indigo-700: oklch(45.7% 0.24 277.023);
--color-indigo-800: oklch(39.8% 0.195 277.366);
--color-indigo-900: oklch(35.9% 0.144 278.697);
--color-indigo-950: oklch(25.7% 0.09 281.288);
--color-violet-50: oklch(96.9% 0.016 293.756);
--color-violet-100: oklch(94.3% 0.029 294.588);
--color-violet-200: oklch(89.4% 0.057 293.283);
--color-violet-300: oklch(81.1% 0.111 293.571);
--color-violet-400: oklch(70.2% 0.183 293.541);
--color-violet-500: oklch(60.6% 0.25 292.717);
--color-violet-600: oklch(54.1% 0.281 293.009);
--color-violet-700: oklch(49.1% 0.27 292.581);
--color-violet-800: oklch(43.2% 0.232 292.759);
--color-violet-900: oklch(38% 0.189 293.745);
--color-violet-950: oklch(28.3% 0.141 291.089);
--color-purple-50: oklch(97.7% 0.014 308.299);
--color-purple-100: oklch(94.6% 0.033 307.174);
--color-purple-200: oklch(90.2% 0.063 306.703);
--color-purple-300: oklch(82.7% 0.119 306.383);
--color-purple-400: oklch(71.4% 0.203 305.504);
--color-purple-500: oklch(62.7% 0.265 303.9);
--color-purple-600: oklch(55.8% 0.288 302.321);
--color-purple-700: oklch(49.6% 0.265 301.924);
--color-purple-800: oklch(43.8% 0.218 303.724);
--color-purple-900: oklch(38.1% 0.176 304.987);
--color-purple-950: oklch(29.1% 0.149 302.717);
--color-fuchsia-50: oklch(97.7% 0.017 320.058);
--color-fuchsia-100: oklch(95.2% 0.037 318.852);
--color-fuchsia-200: oklch(90.3% 0.076 319.62);
--color-fuchsia-300: oklch(83.3% 0.145 321.434);
--color-fuchsia-400: oklch(74% 0.238 322.16);
--color-fuchsia-500: oklch(66.7% 0.295 322.15);
--color-fuchsia-600: oklch(59.1% 0.293 322.896);
--color-fuchsia-700: oklch(51.8% 0.253 323.949);
--color-fuchsia-800: oklch(45.2% 0.211 324.591);
--color-fuchsia-900: oklch(40.1% 0.17 325.612);
--color-fuchsia-950: oklch(29.3% 0.136 325.661);
--color-pink-50: oklch(97.1% 0.014 343.198);
--color-pink-100: oklch(94.8% 0.028 342.258);
--color-pink-200: oklch(89.9% 0.061 343.231);
--color-pink-300: oklch(82.3% 0.12 346.018);
--color-pink-400: oklch(71.8% 0.202 349.761);
--color-pink-500: oklch(65.6% 0.241 354.308);
--color-pink-600: oklch(59.2% 0.249 0.584);
--color-pink-700: oklch(52.5% 0.223 3.958);
--color-pink-800: oklch(45.9% 0.187 3.815);
--color-pink-900: oklch(40.8% 0.153 2.432);
--color-pink-950: oklch(28.4% 0.109 3.907);
--color-rose-50: oklch(96.9% 0.015 12.422);
--color-rose-100: oklch(94.1% 0.03 12.58);
--color-rose-200: oklch(89.2% 0.058 10.001);
--color-rose-300: oklch(81% 0.117 11.638);
--color-rose-400: oklch(71.2% 0.194 13.428);
--color-rose-500: oklch(64.5% 0.246 16.439);
--color-rose-600: oklch(58.6% 0.253 17.585);
--color-rose-700: oklch(51.4% 0.222 16.935);
--color-rose-800: oklch(45.5% 0.188 13.697);
--color-rose-900: oklch(41% 0.159 10.272);
--color-rose-950: oklch(27.1% 0.105 12.094);
--color-slate-50: oklch(98.4% 0.003 247.858);
--color-slate-100: oklch(96.8% 0.007 247.896);
--color-slate-200: oklch(92.9% 0.013 255.508);
--color-slate-300: oklch(86.9% 0.022 252.894);
--color-slate-400: oklch(70.4% 0.04 256.788);
--color-slate-500: oklch(55.4% 0.046 257.417);
--color-slate-600: oklch(44.6% 0.043 257.281);
--color-slate-700: oklch(37.2% 0.044 257.287);
--color-slate-800: oklch(27.9% 0.041 260.031);
--color-slate-900: oklch(20.8% 0.042 265.755);
--color-slate-950: oklch(12.9% 0.042 264.695);
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-300: oklch(87.2% 0.01 258.338);
--color-gray-400: oklch(70.7% 0.022 261.325);
--color-gray-500: oklch(55.1% 0.027 264.364);
--color-gray-600: oklch(44.6% 0.03 256.802);
--color-gray-700: oklch(37.3% 0.034 259.733);
--color-gray-800: oklch(27.8% 0.033 256.848);
--color-gray-900: oklch(21% 0.034 264.665);
--color-gray-950: oklch(13% 0.028 261.692);
--color-zinc-50: oklch(98.5% 0 0);
--color-zinc-100: oklch(96.7% 0.001 286.375);
--color-zinc-200: oklch(92% 0.004 286.32);
--color-zinc-300: oklch(87.1% 0.006 286.286);
--color-zinc-400: oklch(70.5% 0.015 286.067);
--color-zinc-500: oklch(55.2% 0.016 285.938);
--color-zinc-600: oklch(44.2% 0.017 285.786);
--color-zinc-700: oklch(37% 0.013 285.805);
--color-zinc-800: oklch(27.4% 0.006 286.033);
--color-zinc-900: oklch(21% 0.006 285.885);
--color-zinc-950: oklch(14.1% 0.005 285.823);
--color-neutral-50: oklch(98.5% 0 0);
--color-neutral-100: oklch(97% 0 0);
--color-neutral-200: oklch(92.2% 0 0);
--color-neutral-300: oklch(87% 0 0);
--color-neutral-400: oklch(70.8% 0 0);
--color-neutral-500: oklch(55.6% 0 0);
--color-neutral-600: oklch(43.9% 0 0);
--color-neutral-700: oklch(37.1% 0 0);
--color-neutral-800: oklch(26.9% 0 0);
--color-neutral-900: oklch(20.5% 0 0);
--color-neutral-950: oklch(14.5% 0 0);
--color-stone-50: oklch(98.5% 0.001 106.423);
--color-stone-100: oklch(97% 0.001 106.424);
--color-stone-200: oklch(92.3% 0.003 48.717);
--color-stone-300: oklch(86.9% 0.005 56.366);
--color-stone-400: oklch(70.9% 0.01 56.259);
--color-stone-500: oklch(55.3% 0.013 58.071);
--color-stone-600: oklch(44.4% 0.011 73.639);
--color-stone-700: oklch(37.4% 0.01 67.558);
--color-stone-800: oklch(26.8% 0.007 34.298);
--color-stone-900: oklch(21.6% 0.006 56.043);
--color-stone-950: oklch(14.7% 0.004 49.25);
--color-mauve-50: oklch(98.5% 0 0);
--color-mauve-100: oklch(96% 0.003 325.6);
--color-mauve-200: oklch(92.2% 0.005 325.62);
--color-mauve-300: oklch(86.5% 0.012 325.68);
--color-mauve-400: oklch(71.1% 0.019 323.02);
--color-mauve-500: oklch(54.2% 0.034 322.5);
--color-mauve-600: oklch(43.5% 0.029 321.78);
--color-mauve-700: oklch(36.4% 0.029 323.89);
--color-mauve-800: oklch(26.3% 0.024 320.12);
--color-mauve-900: oklch(21.2% 0.019 322.12);
--color-mauve-950: oklch(14.5% 0.008 326);
--color-olive-50: oklch(98.8% 0.003 106.5);
--color-olive-100: oklch(96.6% 0.005 106.5);
--color-olive-200: oklch(93% 0.007 106.5);
--color-olive-300: oklch(88% 0.011 106.6);
--color-olive-400: oklch(73.7% 0.021 106.9);
--color-olive-500: oklch(58% 0.031 107.3);
--color-olive-600: oklch(46.6% 0.025 107.3);
--color-olive-700: oklch(39.4% 0.023 107.4);
--color-olive-800: oklch(28.6% 0.016 107.4);
--color-olive-900: oklch(22.8% 0.013 107.4);
--color-olive-950: oklch(15.3% 0.006 107.1);
--color-mist-50: oklch(98.7% 0.002 197.1);
--color-mist-100: oklch(96.3% 0.002 197.1);
--color-mist-200: oklch(92.5% 0.005 214.3);
--color-mist-300: oklch(87.2% 0.007 219.6);
--color-mist-400: oklch(72.3% 0.014 214.4);
--color-mist-500: oklch(56% 0.021 213.5);
--color-mist-600: oklch(45% 0.017 213.2);
--color-mist-700: oklch(37.8% 0.015 216);
--color-mist-800: oklch(27.5% 0.011 216.9);
--color-mist-900: oklch(21.8% 0.008 223.9);
--color-mist-950: oklch(14.8% 0.004 228.8);
--color-taupe-50: oklch(98.6% 0.002 67.8);
--color-taupe-100: oklch(96% 0.002 17.2);
--color-taupe-200: oklch(92.2% 0.005 34.3);
--color-taupe-300: oklch(86.8% 0.007 39.5);
--color-taupe-400: oklch(71.4% 0.014 41.2);
--color-taupe-500: oklch(54.7% 0.021 43.1);
--color-taupe-600: oklch(43.8% 0.017 39.3);
--color-taupe-700: oklch(36.7% 0.016 35.7);
--color-taupe-800: oklch(26.8% 0.011 36.5);
--color-taupe-900: oklch(21.4% 0.009 43.1);
--color-taupe-950: oklch(14.7% 0.004 49.3);
--color-black: #000;
--color-white: #fff;
--spacing: 0.25rem;
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
--container-3xs: 16rem;
--container-2xs: 18rem;
--container-xs: 20rem;
--container-sm: 24rem;
--container-md: 28rem;
--container-lg: 32rem;
--container-xl: 36rem;
--container-2xl: 42rem;
--container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem;
--container-6xl: 72rem;
--container-7xl: 80rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-base: 1rem;
--text-base--line-height: calc(1.5 / 1);
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--text-5xl: 3rem;
--text-5xl--line-height: 1;
--text-6xl: 3.75rem;
--text-6xl--line-height: 1;
--text-7xl: 4.5rem;
--text-7xl--line-height: 1;
--text-8xl: 6rem;
--text-8xl--line-height: 1;
--text-9xl: 8rem;
--text-9xl--line-height: 1;
--font-weight-thin: 100;
--font-weight-extralight: 200;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-black: 900;
--tracking-tighter: -0.05em;
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 2;
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-4xl: 2rem;
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);
--inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);
--inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);
--drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);
--drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);
--drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);
--drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
--drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
--drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);
--text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15);
--text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2);
--text-shadow-sm:
0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075);
--text-shadow-md:
0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1);
--text-shadow-lg:
0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-bounce: bounce 1s infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes pulse {
50% {
opacity: 0.5;
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: none;
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
--blur-xs: 4px;
--blur-sm: 8px;
--blur-md: 12px;
--blur-lg: 16px;
--blur-xl: 24px;
--blur-2xl: 40px;
--blur-3xl: 64px;
--perspective-dramatic: 100px;
--perspective-near: 300px;
--perspective-normal: 500px;
--perspective-midrange: 800px;
--perspective-distant: 1200px;
--aspect-video: 16 / 9;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: --theme(--font-sans, initial);
--default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial);
--default-font-variation-settings: --theme(--font-sans--font-variation-settings, initial);
--default-mono-font-family: --theme(--font-mono, initial);
--default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings, initial);
--default-mono-font-variation-settings: --theme(--font-mono--font-variation-settings, initial);
}
/* Deprecated */
@theme default inline reference {
--blur: 8px;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
--drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06);
--radius: 0.25rem;
--max-width-prose: 65ch;
}

View File

@@ -0,0 +1,111 @@
package bundler
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
esbuild "github.com/evanw/esbuild/pkg/api"
)
// VendorManifest mirrors frontend/vendor/vendor.json.
type VendorManifest struct {
Entrypoints map[string]string `json:"entrypoints"`
}
// loadVendorManifest reads frontend/vendor/vendor.json — the single place that
// declares the exact entrypoint file for every bundled vendored package.
func loadVendorManifest(vendorDir string) (map[string]string, error) {
data, err := os.ReadFile(filepath.Join(vendorDir, "vendor.json"))
if err != nil {
return nil, err
}
var m VendorManifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parsing vendor.json: %w", err)
}
return m.Entrypoints, nil
}
// vendorManifestPlugin resolves bare imports from the vendor manifest: a package
// listed in vendor.json resolves to its pinned entrypoint file, bypassing the
// package's own `exports` map (which mis-resolves — e.g. solid-js's bare core to
// its SSR build, where effects are no-ops). Subpath imports of a vendored package
// (e.g. pdfjs-dist/build/pdf.worker.min.mjs) resolve to the real file via
// NodePaths; anything not vendored is left external for the import map.
func vendorManifestPlugin(vendorDir string, entrypoints map[string]string) esbuild.Plugin {
abs := make(map[string]string, len(entrypoints))
for spec, rel := range entrypoints {
p, _ := filepath.Abs(filepath.Join(vendorDir, filepath.FromSlash(rel)))
abs[spec] = filepath.ToSlash(p)
}
return esbuild.Plugin{
Name: "vendor-manifest",
Setup: func(build esbuild.PluginBuild) {
// Bare specifiers only (relative/absolute/entry/?suffixed are skipped).
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, ".") || strings.ContainsRune(args.Path, '?') {
return esbuild.OnResolveResult{}, nil
}
if target, ok := abs[args.Path]; ok {
return esbuild.OnResolveResult{Path: target}, nil // pinned entrypoint
}
if vendorHasPackage(vendorDir, args.Path) {
return esbuild.OnResolveResult{}, nil // subpath of a vendored pkg -> NodePaths
}
return esbuild.OnResolveResult{External: true}, nil // not vendored -> import map
})
},
}
}
// vendorHasPackage reports whether a bare specifier maps to a package directory
// under vendorDir, honouring @scope/name.
func vendorHasPackage(vendorDir, spec string) bool {
parts := strings.Split(spec, "/")
pkg := parts[0]
if strings.HasPrefix(pkg, "@") && len(parts) > 1 {
pkg = pkg + "/" + parts[1]
}
info, err := os.Stat(filepath.Join(vendorDir, filepath.FromSlash(pkg)))
return err == nil && info.IsDir()
}
// assetURLPlugin implements a generic `import url from "<path>?url"`: the
// referenced file is emitted as a build asset (via esbuild's file loader,
// controlled by BuildOptions.AssetNames/PublicPath) and the import resolves to
// its served URL. Used for resources that must stay separate files rather than
// be inlined — e.g. the pdfjs Web Worker, which relies on import.meta.url and so
// can't run from a Blob. The asset path lives in the app code that needs it, not
// in the bundler.
func assetURLPlugin() esbuild.Plugin {
const ns = "asset-url"
return esbuild.Plugin{
Name: "asset-url",
Setup: func(build esbuild.PluginBuild) {
build.OnResolve(esbuild.OnResolveOptions{Filter: `\?url$`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
real := strings.TrimSuffix(args.Path, "?url")
r := build.Resolve(real, esbuild.ResolveOptions{
ResolveDir: args.ResolveDir,
Importer: args.Importer,
Kind: args.Kind,
})
if len(r.Errors) > 0 {
return esbuild.OnResolveResult{}, fmt.Errorf("asset-url: cannot resolve %q: %s", real, r.Errors[0].Text)
}
return esbuild.OnResolveResult{Path: r.Path, Namespace: ns}, nil
})
build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: ns}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
data, err := os.ReadFile(args.Path)
if err != nil {
return esbuild.OnLoadResult{}, err
}
contents := string(data)
loader := esbuild.LoaderFile
return esbuild.OnLoadResult{Contents: &contents, Loader: loader}, nil
})
},
}
}