Skip to content
Open
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
194 changes: 194 additions & 0 deletions generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package main

import (
"strings"

"github.com/railwayapp/railpack/core"
"github.com/railwayapp/railpack/core/app"
c "github.com/railwayapp/railpack/core/config"
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/logger"
"github.com/railwayapp/railpack/core/providers"
"github.com/railwayapp/railpack/core/providers/cpp"
"github.com/railwayapp/railpack/core/providers/deno"
"github.com/railwayapp/railpack/core/providers/dotnet"
"github.com/railwayapp/railpack/core/providers/elixir"
"github.com/railwayapp/railpack/core/providers/gleam"
"github.com/railwayapp/railpack/core/providers/golang"
"github.com/railwayapp/railpack/core/providers/java"
"github.com/railwayapp/railpack/core/providers/php"
"github.com/railwayapp/railpack/core/providers/procfile"
"github.com/railwayapp/railpack/core/providers/python"
"github.com/railwayapp/railpack/core/providers/ruby"
"github.com/railwayapp/railpack/core/providers/rust"
"github.com/railwayapp/railpack/core/providers/shell"
"github.com/railwayapp/railpack/core/providers/staticfile"
)

// generateBuildPlan mirrors railpack's core.GenerateBuildPlan but accepts an
// explicit provider list, letting sugapack inject or extend providers without
// patching railpack itself. Every railpack symbol it touches is exported, so
// this compiles against an unmodified railpack dependency.
//
// Adapted from github.com/railwayapp/railpack v0.23.0 core.GenerateBuildPlan.
// Keep in sync when bumping the railpack version.
func generateBuildPlan(a *app.App, env *app.Environment, options *core.GenerateBuildPlanOptions, allProviders []providers.Provider) *core.BuildResult {
log := logger.NewLogger()

config, err := core.GetConfig(a, env, options, log)
if err != nil {
log.LogError("%s", err.Error())
return &core.BuildResult{Success: false, Logs: log.Logs}
}

ctx, err := generate.NewGenerateContext(a, env, config, log)
if err != nil {
log.LogError("%s", err.Error())
return &core.BuildResult{Success: false, Logs: log.Logs}
}

if options.PreviousVersions != nil {
for name, version := range options.PreviousVersions {
ctx.Resolver.SetPreviousVersion(name, version)
}
}

providerToUse, detectedProviderName := selectProvider(ctx, config, allProviders)
ctx.Metadata.Set("providers", detectedProviderName)

if providerToUse != nil {
if err := providerToUse.Plan(ctx); err != nil {
log.LogError("%s", err.Error())
return &core.BuildResult{Success: false, Logs: log.Logs}
}
}

// Support apps that declare a start command in a Procfile.
procfileProvider := &procfile.ProcfileProvider{}
if _, err := procfileProvider.Plan(ctx); err != nil {
log.LogError("%s", err.Error())
return &core.BuildResult{Success: false, Logs: log.Logs}
}

buildPlan, resolvedPackages, err := ctx.Generate()
if err != nil {
log.LogError("%s", err.Error())
return &core.BuildResult{Success: false, Logs: log.Logs}
}

if providerToUse != nil {
providerToUse.CleansePlan(buildPlan)
}

if !core.ValidatePlan(buildPlan, a, log, &core.ValidatePlanOptions{
ErrorMissingStartCommand: options.ErrorMissingStartCommand,
ProviderToUse: providerToUse,
}) {
return &core.BuildResult{Success: false, Logs: log.Logs}
}

return &core.BuildResult{
RailpackVersion: options.RailpackVersion,
Plan: buildPlan,
ResolvedPackages: resolvedPackages,
Metadata: ctx.Metadata.Properties,
DetectedProviders: []string{detectedProviderName},
Logs: log.Logs,
Success: true,
}
}

// selectProvider mirrors railpack's unexported core.getProviders, but takes the
// provider list as a parameter instead of hardcoding GetLanguageProviders().
// This is the single hook the PR was trying to add upstream.
//
// Adapted from github.com/railwayapp/railpack v0.23.0 core.getProviders.
func selectProvider(ctx *generate.GenerateContext, config *c.Config, allProviders []providers.Provider) (providers.Provider, string) {
var providerToUse providers.Provider
var detectedProvider string

// Detect regardless of an explicit config.Provider so we can still report
// what kind of app this is.
for _, provider := range allProviders {
matched, err := provider.Detect(ctx)
if err != nil {
ctx.Logger.LogWarn("Failed to detect provider `%s`: %s", provider.Name(), err.Error())
continue
}

if matched {
detectedProvider = provider.Name()

if config.Provider == nil {
if err := provider.Initialize(ctx); err != nil {
ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", provider.Name(), err.Error())
continue
}
ctx.Logger.LogInfo("Detected %s", capitalizeFirst(provider.Name()))
providerToUse = provider
}
break
}
}

if config.Provider != nil {
provider := providerByName(*config.Provider, allProviders)
if provider == nil {
ctx.Logger.LogWarn("Provider `%s` not found", *config.Provider)
return providerToUse, detectedProvider
}
if err := provider.Initialize(ctx); err != nil {
ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", *config.Provider, err.Error())
return providerToUse, detectedProvider
}
ctx.Logger.LogInfo("Using provider %s from config", capitalizeFirst(*config.Provider))
providerToUse = provider
}

return providerToUse, detectedProvider
}

// providerByName looks up a provider by name (case-insensitive). Replaces
// providers.GetProvider, which only searches railpack's built-in list.
func providerByName(name string, list []providers.Provider) providers.Provider {
for _, p := range list {
if strings.EqualFold(p.Name(), name) {
return p
}
}
return nil
}

// capitalizeFirst reimplements railpack's internal/utils.CapitalizeFirst, which
// lives under an internal/ path and cannot be imported from here.
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}

// sugapackProviders returns the provider list used for plan generation. It
// mirrors railpack's providers.GetLanguageProviders() with the Node provider
// swapped for sugapack's provenance-emitting wrapper. The list is reproduced
// explicitly (rather than mutating the built-in slice) so the ordering — which
// determines detection priority — is visible and any upstream drift shows up as
// a compile error. Keep in sync with GetLanguageProviders when bumping railpack.
func sugapackProviders() []providers.Provider {
return []providers.Provider{
&php.PhpProvider{},
&golang.GoProvider{},
&java.JavaProvider{},
&rust.RustProvider{},
&ruby.RubyProvider{},
&elixir.ElixirProvider{},
&python.PythonProvider{},
&deno.DenoProvider{},
&dotnet.DotnetProvider{},
&nodeProvenanceProvider{}, // wraps node.NodeProvider
&gleam.GleamProvider{},
&cpp.CppProvider{},
&staticfile.StaticfileProvider{},
&shell.ShellProvider{},
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/moby/buildkit v0.28.1
github.com/opencontainers/image-spec v1.1.1
github.com/railwayapp/railpack v0.23.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.8.0
)

Expand All @@ -32,6 +33,7 @@ require (
github.com/containerd/platforms v1.0.0-rc.3 // indirect
github.com/containerd/ttrpc v1.2.8 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
Expand All @@ -56,6 +58,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
Expand Down
2 changes: 1 addition & 1 deletion planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func runPlanner(opts PlannerOptions) error {
StartCommand: opts.StartCmd,
}

result := core.GenerateBuildPlan(a, env, genOpts)
result := generateBuildPlan(a, env, genOpts, sugapackProviders())
printRailpackLogs(os.Stderr, result.Logs)
if !result.Success {
return fmt.Errorf("plan generation failed: %s", railpackErrorSummary(result.Logs))
Expand Down
93 changes: 93 additions & 0 deletions provider_node.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"fmt"

"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/providers/node"
)

// nodeProvenanceProvider wraps railpack's node.NodeProvider to add "decision
// provenance": after the wrapped provider builds its plan, it explains *why* the
// app was classified the way it was and warns when a project that likely wants a
// server was deployed as a static SPA (e.g. a TanStack Start app with no `start`
// script — the motivating case from railpack PR #588).
//
// This is a prototype demonstrating that providers can be extended entirely from
// sugapack: it embeds the built-in provider (so Detect/Initialize/CleansePlan/
// etc. are inherited unchanged) and only reads *exported* railpack surface
// (Metadata, Config.Deploy, GetPackageJson). No railpack patch required.
type nodeProvenanceProvider struct {
node.NodeProvider
}

// Plan runs the wrapped provider's planning, then annotates provenance.
func (p *nodeProvenanceProvider) Plan(ctx *generate.GenerateContext) error {
if err := p.NodeProvider.Plan(ctx); err != nil {
return err
}
p.recordProvenance(ctx)
return nil
}

// recordProvenance inspects the metadata the wrapped provider already set
// (nodeIsSPA, nodeSPAFramework — see node.SetNodeMetadata) plus the user's
// configured start command and package.json, and surfaces an explanation.
func (p *nodeProvenanceProvider) recordProvenance(ctx *generate.GenerateContext) {
meta := ctx.Metadata

if meta.Properties["nodeIsSPA"] != "true" {
meta.Set("provenance.node.classification", "server")
return
}

framework := meta.Properties["nodeSPAFramework"]
meta.Set("provenance.node.classification", "spa")
if framework != "" {
meta.Set("provenance.node.spaFramework", framework)
}

// A custom start command disables SPA classification (node.isSPA), so if we
// reached the SPA branch, either no start command was found or an output dir
// was forced via RAILPACK_SPA_OUTPUT_DIR.
forcedOutputDir, _ := ctx.Env.GetConfigVariable(node.OUTPUT_DIR_VAR)

var reason string
switch {
case forcedOutputDir != "":
reason = fmt.Sprintf("RAILPACK_%s is set (%q), forcing a static SPA deploy.", node.OUTPUT_DIR_VAR, forcedOutputDir)
case framework != "":
reason = fmt.Sprintf("Detected the %s static-site framework and found no start command, so the app is served as a static SPA via Caddy.", framework)
default:
reason = "No start command was found, so the app is served as a static SPA via Caddy."
}
meta.Set("provenance.node.reason", reason)
ctx.Logger.LogInfo("Build decision: %s", reason)

// The motivating case: a framework that can run a server was built as a SPA
// only because it had no start command. Nudge the user toward the fix.
if forcedOutputDir == "" && serverCapableFramework(ctx, p) {
hint := "This project uses a framework that can run as a server (e.g. TanStack Start), " +
"but was built as a static SPA because no `start` script was found. " +
"Add a \"start\" script to package.json (or set RAILPACK_NO_SPA=1) to deploy it as a server."
meta.Set("provenance.node.hint", hint)
ctx.Logger.LogWarn("%s", hint)
}
}

// serverCapableFramework reports whether the project depends on a framework that
// commonly runs its own server but can also be built as a static SPA. Reads
// package.json via the wrapped provider's exported GetPackageJson.
func serverCapableFramework(ctx *generate.GenerateContext, p *nodeProvenanceProvider) bool {
pkg, err := p.NodeProvider.GetPackageJson(ctx.App)
if err != nil || pkg == nil {
return false
}
for dep := range pkg.Dependencies {
switch dep {
case "@tanstack/react-start", "@tanstack/start":

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.

Did we have the same issue with astro sites?

return true
}
}
return false
}
74 changes: 74 additions & 0 deletions provider_node_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/railwayapp/railpack/core"
"github.com/railwayapp/railpack/core/app"
"github.com/stretchr/testify/require"
)

// writeFixture writes a minimal app source tree and returns its directory.
func writeFixture(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, content := range files {
path := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
return dir
}

func planFor(t *testing.T, dir string) *core.BuildResult {
t.Helper()
a, err := app.NewApp(dir)
require.NoError(t, err)
env := app.NewEnvironment(nil)
result := generateBuildPlan(a, env, &core.GenerateBuildPlanOptions{}, sugapackProviders())
printRailpackLogs(os.Stderr, result.Logs)
return result
}

// A TanStack Start app (Vite-based) with no `start` script is classified as a
// static SPA. The provenance wrapper should explain why and warn that a
// server-capable framework was built as a SPA.
func TestNodeProvenance_TanStackStartTreatedAsSPA(t *testing.T) {
dir := writeFixture(t, map[string]string{
"package.json": `{
"name": "tanstack-app",
"scripts": { "build": "vite build" },
"dependencies": { "vite": "^5.0.0", "@tanstack/react-start": "^1.0.0" }
}`,
"vite.config.ts": `export default {}`,
})

result := planFor(t, dir)
require.True(t, result.Success, "plan generation should succeed")

require.Equal(t, "spa", result.Metadata["provenance.node.classification"])
require.Equal(t, "vite", result.Metadata["provenance.node.spaFramework"])
require.Contains(t, result.Metadata["provenance.node.reason"], "static SPA")
require.Contains(t, result.Metadata["provenance.node.hint"], "start")
}

// A plain Node server app (start script, no static framework) should be
// classified as a server with no SPA hint.
func TestNodeProvenance_ServerApp(t *testing.T) {
dir := writeFixture(t, map[string]string{
"package.json": `{
"name": "server-app",
"scripts": { "start": "node index.js" },
"dependencies": { "express": "^4.0.0" }
}`,
"index.js": `require("express")()`,
})

result := planFor(t, dir)
require.True(t, result.Success, "plan generation should succeed")

require.Equal(t, "server", result.Metadata["provenance.node.classification"])
require.Empty(t, result.Metadata["provenance.node.hint"])
}
Loading