Skip to content
Merged
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
10 changes: 9 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -2884,10 +2884,18 @@ libraries/saas-starter-accounts-client/
catalog.codefly.json # the endpoint's catalog entry (subset if --services)
contract.binpb | openapi.json
go/ go.mod, gen/…, accounts_facade.pb.go
typescript/ package.json, src/gen/…, src/accounts_facade.ts
typescript/ package.json, tsconfig.json, src/index.ts, src/gen/…, src/accounts_facade.ts
python/ pyproject.toml, <pkg>/_gen/…, <pkg>/accounts.py
```

The TypeScript library is an ESM package whose root resolves to `dist/index.js`
(types `dist/index.d.ts`). `src/index.ts` is generated output, rewritten on
every run: a barrel re-exporting every facade and every generated binding, so
the package name alone reaches every client and message schema. The scaffold
(`package.json`, `tsconfig.json`) is written once and declares a `typescript`
dev dependency and a `build` script (`tsc -p tsconfig.json`) that produces
`dist/` — the only directory, with the README, that the package ships.

Use `codefly sync library-dependencies` to link a generated library into a
service locally, and `codefly publish library <name>` to distribute it.

Expand Down
65 changes: 63 additions & 2 deletions pkg/generators/client_library.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
Expand All @@ -26,6 +27,10 @@ const (
pinnedConnectVersion = "v1.20.0"
pinnedProtobufVersion = "v1.36.11"
goLanguageVersion = "1.24"
// pinnedTypeScriptVersion builds the generated library's dist/. The
// scaffold declares it so `pnpm build` works in a fresh checkout of the
// library without the consumer supplying a compiler.
pinnedTypeScriptVersion = "^5.9.3"
)

// langDir itself is never wiped by any of the wipe*/merge*/finalize*
Expand Down Expand Up @@ -114,9 +119,53 @@ func finalizeTypeScriptLibrary(langDir, npmPackage string) (generatedLanguageExp
if err := writeTypeScriptScaffold(langDir, npmPackage); err != nil {
return generatedLanguageExport{}, err
}
if err := writeTypeScriptIndex(langDir); err != nil {
return generatedLanguageExport{}, err
}
return generatedLanguageExport{Name: "typescript", Path: "typescript/", Exports: []string{npmPackage}}, nil
}

// writeTypeScriptIndex writes src/index.ts: the barrel re-exporting every
// hoisted facade and every generated binding, and so the one file
// package.json's "exports" names. It is generated output, not a scaffold —
// src/ is wholly owned by this command (wipeTypeScriptLibrary removes it) and
// which files exist depends on the contracts generated — so it is rewritten on
// every run instead of written once. Without it the library declares an entry
// point nothing produced and cannot be resolved at all.
func writeTypeScriptIndex(langDir string) error {
srcDir := filepath.Join(langDir, "src")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
return err
}
index := filepath.Join(srcDir, "index.ts")
var modules []string
if err := filepath.WalkDir(srcDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || path == index || !strings.HasSuffix(entry.Name(), ".ts") {
return nil
}
relative, relErr := filepath.Rel(srcDir, path)
if relErr != nil {
return relErr
}
// The ".js" specifier the generated bindings and facades already use:
// the sources are ESM and resolve by emitted path, not by source name.
modules = append(modules, "./"+strings.TrimSuffix(filepath.ToSlash(relative), ".ts")+".js")
return nil
}); err != nil {
return err
}
sort.Strings(modules)
var content strings.Builder
content.WriteString("// generated by codefly generate client; do not edit\n")
for _, module := range modules {
fmt.Fprintf(&content, "export * from %q;\n", module)
}
return os.WriteFile(index, []byte(content.String()), 0o644) //nolint:gosec
}

// wipePythonLibrary clears pkgDir's generated content once, before any entry
// is merged in.
func wipePythonLibrary(pkgDir string) error {
Expand Down Expand Up @@ -254,6 +303,9 @@ func assembleOpenAPILibrary(ctx context.Context, lang languages.Language, langDi
if err := writeTypeScriptScaffold(langDir, npmPackage); err != nil {
return generatedLanguageExport{}, err
}
if err := writeTypeScriptIndex(langDir); err != nil {
return generatedLanguageExport{}, err
}
return generatedLanguageExport{Name: "typescript", Path: "typescript/", Exports: []string{npmPackage}}, nil
}

Expand Down Expand Up @@ -594,21 +646,30 @@ func writeTypeScriptScaffold(langDir, npmPackage string) error {
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"@connectrpc/connect": "^2.0.0"
},
"devDependencies": {
"typescript": %q
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
`, npmPackage)
`, npmPackage, pinnedTypeScriptVersion)
if err := writeFileIfMissing(filepath.Join(langDir, "package.json"), []byte(packageJSON)); err != nil {
return err
}
Expand Down
108 changes: 108 additions & 0 deletions pkg/generators/client_library_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package generators

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -141,3 +142,110 @@ addopts = "-q"
t.Fatalf("ensurePythonDependencies is not idempotent:\n%s", twice)
}
}

// TestTypeScriptLibraryDeclaresAnEntryPointItProduces pins the two halves of
// the generated package that must agree: package.json's "exports" and the file
// tree. They lived in different functions and disagreed — the scaffold named
// ./src/index.ts and nothing ever wrote it, so every generated TypeScript
// library was unresolvable by the name it published under. A barrel that is
// not regenerated is the same bug one run later, since src/ is wiped per run.
func TestTypeScriptLibraryDeclaresAnEntryPointItProduces(t *testing.T) {
langDir := t.TempDir()
genDir := filepath.Join(langDir, "src", "gen")
if err := os.MkdirAll(genDir, 0o755); err != nil {
t.Fatal(err)
}
// What buf leaves behind: bindings under gen/, a facade hoisted beside it.
for path, content := range map[string]string{
filepath.Join(genDir, "api_pb.ts"): "export const Pdf = {};\n",
filepath.Join(langDir, "src", "pdf_facade.ts"): "export const pdf = {};\n",
} {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

export, err := finalizeTypeScriptLibrary(langDir, "@acme/pdf-client")
if err != nil {
t.Fatalf("finalizeTypeScriptLibrary: %v", err)
}
if export.Name != "typescript" {
t.Fatalf("export name = %q, want typescript", export.Name)
}

manifest, err := os.ReadFile(filepath.Join(langDir, "package.json"))
if err != nil {
t.Fatal(err)
}
var pkg struct {
Exports map[string]struct {
Types string `json:"types"`
Import string `json:"import"`
} `json:"exports"`
Files []string `json:"files"`
Scripts map[string]string `json:"scripts"`
}
if err = json.Unmarshal(manifest, &pkg); err != nil {
t.Fatalf("package.json is not valid JSON: %v\n%s", err, manifest)
}

// The entry point must be something tsconfig emits, "files" must ship it,
// and a build script must exist to produce it.
root, ok := pkg.Exports["."]
if !ok {
t.Fatalf(`package.json has no "." export: %s`, manifest)
}
for _, declared := range []string{root.Import, root.Types} {
if !strings.HasPrefix(declared, "./dist/") {
t.Errorf("entry %q is not under ./dist/, which is tsconfig's outDir and the only thing %v ships", declared, pkg.Files)
}
}
if pkg.Scripts["build"] == "" {
t.Error(`package.json declares a dist/ entry point but no "build" script to emit it`)
}
shipped := false
for _, pattern := range pkg.Files {
if pattern == "dist" {
shipped = true
}
}
if !shipped {
t.Errorf(`"files" %v does not ship dist/, so the published package would omit its own entry point`, pkg.Files)
}

// The barrel itself: present, and reaching every generated module.
index, err := os.ReadFile(filepath.Join(langDir, "src", "index.ts"))
if err != nil {
t.Fatalf("src/index.ts, the file tsconfig compiles into the declared entry point, was not written: %v", err)
}
for _, want := range []string{`export * from "./gen/api_pb.js";`, `export * from "./pdf_facade.js";`} {
if !strings.Contains(string(index), want) {
t.Errorf("src/index.ts does not re-export %s\ngot:\n%s", want, index)
}
}

// Regenerating must rewrite the barrel, not leave a stale one: src/ is
// wiped per run, so a write-once index would vanish on the second run.
if err = wipeTypeScriptLibrary(langDir); err != nil {
t.Fatal(err)
}
if err = os.MkdirAll(genDir, 0o755); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(genDir, "other_pb.ts"), []byte("export const Other = {};\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err = finalizeTypeScriptLibrary(langDir, "@acme/pdf-client"); err != nil {
t.Fatalf("second finalizeTypeScriptLibrary: %v", err)
}
index, err = os.ReadFile(filepath.Join(langDir, "src", "index.ts"))
if err != nil {
t.Fatalf("src/index.ts was not rewritten after a wipe: %v", err)
}
if !strings.Contains(string(index), `export * from "./gen/other_pb.js";`) {
t.Errorf("the regenerated barrel missed the new module:\n%s", index)
}
if strings.Contains(string(index), "api_pb") {
t.Errorf("the regenerated barrel still re-exports a module the wipe removed:\n%s", index)
}
}
Loading