Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions detect.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use the detected runtime? If the railpack output uses a bun base image for the runtime as an example this will fail as node will not be available

}
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
}
99 changes: 99 additions & 0 deletions detect_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
76 changes: 76 additions & 0 deletions planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/railwayapp/railpack/core"
"github.com/railwayapp/railpack/core/app"
Expand All @@ -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)
Expand All @@ -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))
}
Expand Down Expand Up @@ -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] == '/' {
Expand Down
70 changes: 70 additions & 0 deletions planner_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading