From 649f63ed7e096c19aa305c6b7703db245450a7b5 Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Wed, 3 Jun 2026 16:32:31 +1000 Subject: [PATCH] add server framework detection and overrides for tanstack --- detect.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++ detect_test.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++ planner.go | 76 +++++++++++++++++++++++++++++++++++++ planner_test.go | 70 ++++++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 detect.go create mode 100644 detect_test.go create mode 100644 planner_test.go diff --git a/detect.go b/detect.go new file mode 100644 index 0000000..1b488e6 --- /dev/null +++ b/detect.go @@ -0,0 +1,94 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +type packageJSON struct { + Scripts map[string]string `json:"scripts"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +func (p *packageJSON) hasAnyDependency(names ...string) bool { + for _, name := range names { + if _, ok := p.Dependencies[name]; ok { + return true + } + if _, ok := p.DevDependencies[name]; ok { + return true + } + } + return false +} + +// serverFramework describes how a framework should be deployed once detected. +type serverFramework struct { + Name string + StartCmd string +} + +// frameworkContext is the read access detectors get to the project source. +// Widen it (e.g. with the source root + a file reader for Astro's +// astro.config.mjs) as frameworks need more than dependency names; detector +// signatures stay the same. +type frameworkContext struct { + pkg *packageJSON +} + +type serverFrameworkDetector func(*frameworkContext) (serverFramework, bool) + +// serverFrameworkDetectors lists the frameworks we steer onto railpack's +// server path. Add a framework by writing a detector and appending it here. +var serverFrameworkDetectors = []serverFrameworkDetector{ + detectTanstackStart, +} + +func detectTanstackStart(c *frameworkContext) (serverFramework, bool) { + // Scaffolded with the Nitro adapter, TanStack Start has no `start` script, + // so railpack serves the build output as a static SPA. Nitro emits a server + // at .output/server/index.mjs (same as Nuxt). @tanstack/start is the old name. + if c.pkg.hasAnyDependency("@tanstack/react-start", "@tanstack/solid-start", "@tanstack/start") { + return serverFramework{Name: "TanStack Start", StartCmd: "node .output/server/index.mjs"}, true + } + return serverFramework{}, false +} + +// detectServerFramework finds full-stack frameworks that railpack misclassifies +// as static SPAs. railpack reads Vite + a build script + no `start` script as +// an SPA and serves it with Caddy (NIT-1230). We only intervene when there is +// no `start` script — with one, railpack already deploys a server. +func detectServerFramework(sourceDir string) (serverFramework, bool) { + pkg, err := readPackageJSON(sourceDir) + if err != nil { + return serverFramework{}, false + } + if strings.TrimSpace(pkg.Scripts["start"]) != "" { + return serverFramework{}, false + } + + ctx := &frameworkContext{pkg: pkg} + for _, detect := range serverFrameworkDetectors { + if fw, ok := detect(ctx); ok { + return fw, true + } + } + return serverFramework{}, false +} + +func readPackageJSON(sourceDir string) (*packageJSON, error) { + data, err := os.ReadFile(filepath.Join(sourceDir, "package.json")) + if err != nil { + return nil, err + } + + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, err + } + + return &pkg, nil +} diff --git a/detect_test.go b/detect_test.go new file mode 100644 index 0000000..fc1af26 --- /dev/null +++ b/detect_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writePackageJSON(t *testing.T, contents string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(contents), 0644); err != nil { + t.Fatalf("writing package.json: %v", err) + } + return dir +} + +func TestDetectServerFramework_TanstackStart(t *testing.T) { + cases := []struct { + name string + pkg string + }{ + { + name: "react-start in dependencies", + pkg: `{ + "scripts": {"build": "vite build"}, + "dependencies": {"@tanstack/react-start": "^1.0.0", "vite": "^6.0.0"} + }`, + }, + { + name: "solid-start in devDependencies", + pkg: `{ + "scripts": {"build": "vite build"}, + "devDependencies": {"@tanstack/solid-start": "^1.0.0"} + }`, + }, + { + name: "legacy @tanstack/start", + pkg: `{"scripts": {"build": "vite build"}, "dependencies": {"@tanstack/start": "^1.0.0"}}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := writePackageJSON(t, tc.pkg) + fw, ok := detectServerFramework(dir) + if !ok { + t.Fatalf("expected TanStack Start to be detected") + } + if fw.Name != "TanStack Start" { + t.Errorf("name = %q, want %q", fw.Name, "TanStack Start") + } + if fw.StartCmd != "node .output/server/index.mjs" { + t.Errorf("startCmd = %q, want %q", fw.StartCmd, "node .output/server/index.mjs") + } + }) + } +} + +func TestDetectServerFramework_StartScriptWins(t *testing.T) { + // An explicit start script means railpack already deploys it as a server, + // so we must not override. + dir := writePackageJSON(t, `{ + "scripts": {"build": "vite build", "start": "node ./server.js"}, + "dependencies": {"@tanstack/react-start": "^1.0.0"} + }`) + + if _, ok := detectServerFramework(dir); ok { + t.Errorf("expected no override when a start script is present") + } +} + +func TestDetectServerFramework_PlainVite(t *testing.T) { + // A plain Vite SPA should be left alone for railpack to serve statically. + dir := writePackageJSON(t, `{ + "scripts": {"build": "vite build"}, + "dependencies": {"vite": "^6.0.0", "react": "^19.0.0"} + }`) + + if _, ok := detectServerFramework(dir); ok { + t.Errorf("expected no override for a plain Vite SPA") + } +} + +func TestDetectServerFramework_NoPackageJSON(t *testing.T) { + if _, ok := detectServerFramework(t.TempDir()); ok { + t.Errorf("expected no detection when package.json is absent") + } +} + +func TestHasConfigVar(t *testing.T) { + envs := []string{"NODE_ENV=production", "RAILPACK_NO_SPA=1"} + if !hasConfigVar(envs, "RAILPACK_NO_SPA") { + t.Errorf("expected RAILPACK_NO_SPA to be detected") + } + if hasConfigVar(envs, "RAILPACK_SPA_OUTPUT_DIR") { + t.Errorf("did not expect RAILPACK_SPA_OUTPUT_DIR to be detected") + } +} diff --git a/planner.go b/planner.go index 2968a8e..e7a75b3 100644 --- a/planner.go +++ b/planner.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/railwayapp/railpack/core" "github.com/railwayapp/railpack/core/app" @@ -28,6 +29,11 @@ func runPlanner(opts PlannerOptions) error { return fmt.Errorf("creating app: %w", err) } + // Steer Vite-based SSR frameworks onto railpack's server path. Without a + // `start` script, railpack sees Vite + a build script and deploys them as a + // static SPA behind Caddy (NIT-1230); the real server never runs. + applyServerFrameworkOverrides(&opts) + env, err := app.FromEnvs(opts.Envs) if err != nil { return fmt.Errorf("creating environment: %w", err) @@ -40,6 +46,11 @@ func runPlanner(opts PlannerOptions) error { result := core.GenerateBuildPlan(a, env, genOpts) printRailpackLogs(os.Stderr, result.Logs) + + // Surface railpack's detection decisions so a misdetection shows up in the + // build logs instead of silently shipping the wrong container. + logPlanSummary(result) + if !result.Success { return fmt.Errorf("plan generation failed: %s", railpackErrorSummary(result.Logs)) } @@ -95,6 +106,71 @@ func railpackErrorSummary(logs []logger.Msg) string { } } +// applyServerFrameworkOverrides forces railpack onto its server deploy path for +// Vite-based SSR frameworks it would otherwise serve as a static SPA. It is a +// no-op when the caller supplied an explicit start command or explicitly opted +// into an SPA build via RAILPACK_SPA_OUTPUT_DIR. +func applyServerFrameworkOverrides(opts *PlannerOptions) { + if opts.StartCmd != "" || hasConfigVar(opts.Envs, "RAILPACK_SPA_OUTPUT_DIR") { + return + } + + fw, ok := detectServerFramework(opts.SourceDir) + if !ok { + return + } + + // The start command alone flips railpack off its SPA path: a non-default + // start command makes railpack's hasCustomStartCommand (which isSPA checks) + // true. We deliberately do NOT set RAILPACK_NO_SPA — railpack turns plan-time + // env vars into BuildKit secret mounts, so an unplumbed RAILPACK_NO_SPA fails + // the build with "secret RAILPACK_NO_SPA: not found". + opts.StartCmd = fw.StartCmd + + fmt.Fprintf(os.Stderr, "[sugapack] detected %s without a start script; deploying as a server (start: %q) instead of a static SPA\n", fw.Name, fw.StartCmd) +} + +// hasConfigVar reports whether envs already sets the given KEY=... variable. +func hasConfigVar(envs []string, key string) bool { + prefix := key + "=" + for _, e := range envs { + if strings.HasPrefix(e, prefix) { + return true + } + } + return false +} + +// logPlanSummary writes railpack's detection outcome to stderr. The build +// runner streams stderr into the build logs, so this is what makes an +// auto-detect decision (runtime, SPA vs server, start command) visible to the +// user instead of being buried in the frontend. +func logPlanSummary(result *core.BuildResult) { + if result == nil { + return + } + + for _, msg := range result.Logs { + fmt.Fprintf(os.Stderr, "[railpack] %s: %s\n", msg.Level, msg.Msg) + } + + if len(result.DetectedProviders) > 0 { + fmt.Fprintf(os.Stderr, "[sugapack] detected providers: %s\n", strings.Join(result.DetectedProviders, ", ")) + } + + if runtime := result.Metadata["nodeRuntime"]; runtime != "" { + isSPA := result.Metadata["nodeIsSPA"] + if isSPA == "" { + isSPA = "false" + } + fmt.Fprintf(os.Stderr, "[sugapack] node runtime: %s (served as static SPA: %s)\n", runtime, isSPA) + } + + if result.Plan != nil && result.Plan.Deploy.StartCmd != "" { + fmt.Fprintf(os.Stderr, "[sugapack] start command: %s\n", result.Plan.Deploy.StartCmd) + } +} + func getDir(path string) string { for i := len(path) - 1; i >= 0; i-- { if path[i] == '/' { diff --git a/planner_test.go b/planner_test.go new file mode 100644 index 0000000..87d0bd8 --- /dev/null +++ b/planner_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "slices" + "testing" +) + +func TestApplyServerFrameworkOverrides_TanstackStart(t *testing.T) { + dir := writePackageJSON(t, `{ + "scripts": {"build": "vite build"}, + "dependencies": {"@tanstack/react-start": "^1.0.0"} + }`) + opts := PlannerOptions{SourceDir: dir} + + applyServerFrameworkOverrides(&opts) + + if opts.StartCmd != "node .output/server/index.mjs" { + t.Errorf("startCmd = %q, want injected server command", opts.StartCmd) + } + // railpack turns plan-time envs into build secrets, so we must not inject + // RAILPACK_NO_SPA — the start command alone forces the server path. + if len(opts.Envs) != 0 { + t.Errorf("envs = %v, want none injected", opts.Envs) + } +} + +func TestApplyServerFrameworkOverrides_ExplicitStartCmdWins(t *testing.T) { + dir := writePackageJSON(t, `{ + "scripts": {"build": "vite build"}, + "dependencies": {"@tanstack/react-start": "^1.0.0"} + }`) + opts := PlannerOptions{SourceDir: dir, StartCmd: "node custom.js"} + + applyServerFrameworkOverrides(&opts) + + if opts.StartCmd != "node custom.js" { + t.Errorf("startCmd = %q, want it left untouched", opts.StartCmd) + } + if slices.Contains(opts.Envs, "RAILPACK_NO_SPA=1") { + t.Errorf("did not expect RAILPACK_NO_SPA to be injected over an explicit start command") + } +} + +func TestApplyServerFrameworkOverrides_SPAOptInWins(t *testing.T) { + dir := writePackageJSON(t, `{ + "scripts": {"build": "vite build"}, + "dependencies": {"@tanstack/react-start": "^1.0.0"} + }`) + opts := PlannerOptions{SourceDir: dir, Envs: []string{"RAILPACK_SPA_OUTPUT_DIR=dist"}} + + applyServerFrameworkOverrides(&opts) + + if opts.StartCmd != "" { + t.Errorf("startCmd = %q, want empty when user opted into an SPA build", opts.StartCmd) + } +} + +func TestApplyServerFrameworkOverrides_PlainViteUntouched(t *testing.T) { + dir := writePackageJSON(t, `{ + "scripts": {"build": "vite build"}, + "dependencies": {"vite": "^6.0.0"} + }`) + opts := PlannerOptions{SourceDir: dir} + + applyServerFrameworkOverrides(&opts) + + if opts.StartCmd != "" || len(opts.Envs) != 0 { + t.Errorf("plain Vite SPA should be left untouched, got startCmd=%q envs=%v", opts.StartCmd, opts.Envs) + } +}