diff --git a/pkg/runtime/node/build.go b/pkg/runtime/node/build.go index c3bc6fbf19..006b40ecaa 100644 --- a/pkg/runtime/node/build.go +++ b/pkg/runtime/node/build.go @@ -15,6 +15,7 @@ import ( "github.com/sst/sst/v3/pkg/js" "github.com/sst/sst/v3/pkg/process" "github.com/sst/sst/v3/pkg/runtime" + "gopkg.in/yaml.v3" ) var forceExternal = []string{ @@ -273,7 +274,11 @@ func (r *Runtime) Build(ctx context.Context, input *runtime.BuildInput) (*runtim } dependencies := map[string]string{} for _, pkg := range installPackages { - dependencies[pkg] = resolveInstallVersion(pkg, properties.Install, parsed) + version, err := resolveInstallVersion(pkg, properties.Install, filepath.Dir(src), parsed) + if err != nil { + return nil, err + } + dependencies[pkg] = version } outPkg := filepath.Join(input.Out(), "package.json") outFile, err := os.Create(outPkg) @@ -320,6 +325,22 @@ func (r *Runtime) Build(ctx context.Context, input *runtime.BuildInput) (*runtim }, nil } +type catalogSource struct { + Catalog map[string]string `json:"catalog" yaml:"catalog"` + Catalogs map[string]map[string]string `json:"catalogs" yaml:"catalogs"` +} + +type bunPackageJSON struct { + Catalog map[string]string `json:"catalog"` + Catalogs map[string]map[string]string `json:"catalogs"` + Workspaces json.RawMessage `json:"workspaces"` +} + +type bunWorkspaces struct { + Catalog map[string]string `json:"catalog"` + Catalogs map[string]map[string]string `json:"catalogs"` +} + func resolveInstallPackages(install map[string]string) []string { result := make([]string, 0, len(install)) for pkg := range install { @@ -328,12 +349,150 @@ func resolveInstallPackages(install map[string]string) []string { return result } -func resolveInstallVersion(pkg string, install map[string]string, packageJSON js.PackageJson) string { - if version, ok := install[pkg]; ok && version != "" && version != "*" { - return version +func resolveInstallVersion(pkg string, install map[string]string, dir string, packageJSON js.PackageJson) (string, error) { + version := install[pkg] + if version == "" || version == "*" { + version = packageJSON.Dependencies[pkg] } - if version := packageJSON.Dependencies[pkg]; version != "" { - return version + if version == "" { + return "*", nil + } + if strings.HasPrefix(version, "catalog:") { + var err error + version, err = resolveCatalogVersion(dir, pkg, version) + if err != nil { + return "", err + } + } + for _, prefix := range []string{"catalog:", "workspace:", "file:", "link:", "portal:", "patch:"} { + if strings.HasPrefix(version, prefix) { + return "", fmt.Errorf("could not determine an npm-compatible version for %q in nodejs.install: found %q using %q; pin the version explicitly", pkg, version, prefix) + } + } + return version, nil +} + +func resolveCatalogVersion(dir string, pkg string, version string) (string, error) { + workspacePath, err := fs.FindUp(dir, "pnpm-workspace.yaml") + if err == nil { + return resolvePnpmCatalogVersion(workspacePath, pkg, version) + } + resolved, found, err := resolveBunCatalogVersion(dir, pkg, version) + if err != nil { + return "", err + } + if found { + return resolved, nil + } + return "", fmt.Errorf("could not determine an npm-compatible version for %q in nodejs.install: found %q but pnpm-workspace.yaml was not found and no Bun catalog was found in an ancestor package.json; pin the version explicitly", pkg, version) +} + +func resolvePnpmCatalogVersion(workspacePath string, pkg string, version string) (string, error) { + data, err := os.ReadFile(workspacePath) + if err != nil { + return "", err + } + var workspace catalogSource + if err := yaml.Unmarshal(data, &workspace); err != nil { + return "", err + } + resolved, ok := resolveCatalogEntry(pkg, version, workspace) + if !ok { + return "", fmt.Errorf("could not determine an npm-compatible version for %q in nodejs.install: found %q but no matching catalog entry exists in pnpm-workspace.yaml; pin the version explicitly", pkg, version) + } + return resolved, nil +} + +func resolveBunCatalogVersion(dir string, pkg string, version string) (string, bool, error) { + currentDir := dir + for { + packagePath := filepath.Join(currentDir, "package.json") + data, err := os.ReadFile(packagePath) + if err == nil { + source, found, err := parseBunCatalogSource(data) + if err != nil { + return "", false, err + } + if found { + resolved, ok := resolveCatalogEntry(pkg, version, source) + if !ok { + return "", true, fmt.Errorf("could not determine an npm-compatible version for %q in nodejs.install: found %q but no matching catalog entry exists in %s; pin the version explicitly", pkg, version, packagePath) + } + return resolved, true, nil + } + } else if !os.IsNotExist(err) { + return "", false, err + } + + if currentDir == filepath.Dir(currentDir) { + break + } + currentDir = filepath.Dir(currentDir) + } + return "", false, nil +} + +func parseBunCatalogSource(data []byte) (catalogSource, bool, error) { + var manifest bunPackageJSON + if err := json.Unmarshal(data, &manifest); err != nil { + return catalogSource{}, false, err + } + source := catalogSource{ + Catalog: manifest.Catalog, + Catalogs: manifest.Catalogs, + } + workspaceSource, found, err := parseBunWorkspacesCatalogSource(manifest.Workspaces) + if err != nil { + return catalogSource{}, false, err + } + if found { + if workspaceSource.Catalog != nil { + source.Catalog = workspaceSource.Catalog + } + if workspaceSource.Catalogs != nil { + if source.Catalogs == nil { + source.Catalogs = map[string]map[string]string{} + } + for name, catalog := range workspaceSource.Catalogs { + source.Catalogs[name] = catalog + } + } + } + if source.Catalog == nil && len(source.Catalogs) == 0 { + return catalogSource{}, false, nil + } + return source, true, nil +} + +func parseBunWorkspacesCatalogSource(raw json.RawMessage) (catalogSource, bool, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed[0] != '{' { + return catalogSource{}, false, nil + } + var workspaces bunWorkspaces + if err := json.Unmarshal(raw, &workspaces); err != nil { + return catalogSource{}, false, err + } + if workspaces.Catalog == nil && len(workspaces.Catalogs) == 0 { + return catalogSource{}, false, nil + } + return catalogSource{ + Catalog: workspaces.Catalog, + Catalogs: workspaces.Catalogs, + }, true, nil +} + +func resolveCatalogEntry(pkg string, version string, source catalogSource) (string, bool) { + catalogName := strings.TrimSpace(strings.TrimPrefix(version, "catalog:")) + var catalog map[string]string + if catalogName == "" || catalogName == "default" { + catalog = source.Catalog + if catalog == nil { + catalog = source.Catalogs["default"] + } + } else { + catalog = source.Catalogs[catalogName] } - return "*" + resolved := catalog[pkg] + return resolved, resolved != "" } diff --git a/pkg/runtime/node/build_test.go b/pkg/runtime/node/build_test.go index f6b5192265..f22efcdb91 100644 --- a/pkg/runtime/node/build_test.go +++ b/pkg/runtime/node/build_test.go @@ -1,6 +1,9 @@ package node import ( + "os" + "path/filepath" + "strings" "testing" "github.com/sst/sst/v3/pkg/js" @@ -11,37 +14,244 @@ func TestResolveInstallVersion(t *testing.T) { name string pkg string install map[string]string - deps map[string]string + setup func(t *testing.T) (string, js.PackageJson) want string + wantErr string }{ { name: "explicit version overrides package json", pkg: "sharp", install: map[string]string{"sharp": "0.33.5"}, - deps: map[string]string{"sharp": "0.32.6"}, - want: "0.33.5", + setup: func(t *testing.T) (string, js.PackageJson) { + return "", js.PackageJson{Dependencies: map[string]string{"sharp": "0.32.6"}} + }, + want: "0.33.5", }, { name: "wildcard falls back to package json", pkg: "sharp", install: map[string]string{"sharp": "*"}, - deps: map[string]string{"sharp": "0.32.6"}, - want: "0.32.6", + setup: func(t *testing.T) (string, js.PackageJson) { + return "", js.PackageJson{Dependencies: map[string]string{"sharp": "0.32.6"}} + }, + want: "0.32.6", }, { name: "missing package falls back to wildcard", pkg: "sharp", install: map[string]string{"sharp": "*"}, - want: "*", + setup: func(t *testing.T) (string, js.PackageJson) { + return "", js.PackageJson{} + }, + want: "*", + }, + { + name: "catalog spec resolves from pnpm workspace", + pkg: "sharp", + install: map[string]string{"sharp": "*"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "pnpm-workspace.yaml"), "catalogs:\n shared:\n sharp: 0.32.6\n") + return pkgDir, js.PackageJson{Dependencies: map[string]string{"sharp": "catalog:shared"}} + }, + want: "0.32.6", + }, + { + name: "explicit catalog spec resolves from pnpm workspace", + pkg: "sharp", + install: map[string]string{"sharp": "catalog:shared"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "pnpm-workspace.yaml"), "catalogs:\n shared:\n sharp: 0.32.6\n") + return pkgDir, js.PackageJson{} + }, + want: "0.32.6", + }, + { + name: "missing catalog entry returns error", + pkg: "sharp", + install: map[string]string{"sharp": "*"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "pnpm-workspace.yaml"), "catalogs:\n shared:\n other: 1.0.0\n") + return pkgDir, js.PackageJson{Dependencies: map[string]string{"sharp": "catalog:shared"}} + }, + wantErr: "no matching catalog entry exists", + }, + { + name: "catalog spec resolves from bun top level catalog", + pkg: "sharp", + install: map[string]string{"sharp": "*"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "package.json"), `{ + "catalog": { + "sharp": "0.32.6" + } +}`) + return pkgDir, js.PackageJson{Dependencies: map[string]string{"sharp": "catalog:"}} + }, + want: "0.32.6", + }, + { + name: "explicit catalog spec resolves from bun top level catalogs", + pkg: "sharp", + install: map[string]string{"sharp": "catalog:shared"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "package.json"), `{ + "catalogs": { + "shared": { + "sharp": "0.32.6" + } + } +}`) + return pkgDir, js.PackageJson{} + }, + want: "0.32.6", + }, + { + name: "catalog spec resolves from bun workspaces catalog", + pkg: "sharp", + install: map[string]string{"sharp": "*"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "package.json"), `{ + "workspaces": { + "packages": ["packages/*"], + "catalog": { + "sharp": "0.32.6" + } + } +}`) + return pkgDir, js.PackageJson{Dependencies: map[string]string{"sharp": "catalog:"}} + }, + want: "0.32.6", + }, + { + name: "explicit catalog spec resolves from bun workspaces catalogs", + pkg: "sharp", + install: map[string]string{"sharp": "catalog:shared"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "package.json"), `{ + "workspaces": { + "packages": ["packages/*"], + "catalogs": { + "shared": { + "sharp": "0.32.6" + } + } + } +}`) + return pkgDir, js.PackageJson{} + }, + want: "0.32.6", + }, + { + name: "catalog spec resolves from bun top level catalog with workspaces array", + pkg: "sharp", + install: map[string]string{"sharp": "*"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "package.json"), `{ + "workspaces": ["packages/*"], + "catalog": { + "sharp": "0.32.6" + } +}`) + return pkgDir, js.PackageJson{Dependencies: map[string]string{"sharp": "catalog:"}} + }, + want: "0.32.6", + }, + { + name: "missing bun catalog entry returns error", + pkg: "sharp", + install: map[string]string{"sharp": "*"}, + setup: func(t *testing.T) (string, js.PackageJson) { + dir := t.TempDir() + pkgDir := filepath.Join(dir, "packages", "functions") + mustMkdirAll(t, pkgDir) + mustWriteFile(t, filepath.Join(dir, "package.json"), `{ + "catalogs": { + "shared": { + "other": "1.0.0" + } + } +}`) + return pkgDir, js.PackageJson{Dependencies: map[string]string{"sharp": "catalog:shared"}} + }, + wantErr: "no matching catalog entry exists in", + }, + { + name: "workspace spec returns error", + pkg: "sharp", + install: map[string]string{"sharp": "*"}, + setup: func(t *testing.T) (string, js.PackageJson) { + return "", js.PackageJson{Dependencies: map[string]string{"sharp": "workspace:^"}} + }, + wantErr: "found \"workspace:^\" using \"workspace:\"", + }, + { + name: "explicit workspace spec returns error", + pkg: "sharp", + install: map[string]string{"sharp": "workspace:^"}, + setup: func(t *testing.T) (string, js.PackageJson) { + return "", js.PackageJson{} + }, + wantErr: "found \"workspace:^\" using \"workspace:\"", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := resolveInstallVersion(tt.pkg, tt.install, js.PackageJson{Dependencies: tt.deps}) + dir, packageJSON := tt.setup(t) + got, err := resolveInstallVersion(tt.pkg, tt.install, dir, packageJSON) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("resolveInstallVersion() error = nil, want %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("resolveInstallVersion() error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolveInstallVersion() error = %v", err) + } if got != tt.want { t.Fatalf("resolveInstallVersion() = %q, want %q", got, tt.want) } }) } } + +func mustMkdirAll(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } +} + +func mustWriteFile(t *testing.T, path string, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +}