From 17f1c304f77130360f0b9537ae290c12f74d4be7 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sat, 17 Oct 2026 16:09:15 -0400 Subject: [PATCH 1/2] fix(generate): the generated TypeScript library declares an entry point it produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate client --language typescript` wrote a package.json whose "exports" named ./src/index.ts and nothing ever wrote that file, so every generated TypeScript library was unresolvable by the name it published under. The scaffold was also internally inconsistent: it named a source entry point while "files" shipped only dist/ and tsconfig emitted there, and it declared no compiler or build script to produce dist/ at all. The entry point is now generated: src/index.ts is a barrel re-exporting every hoisted facade and every generated binding, written on each run rather than once, because src/ is wholly owned by the command and wiped per run — a write-once barrel would vanish on the second run and go stale on the first. "exports" now names dist/ with its types, matching tsconfig's outDir and what "files" ships, and the scaffold declares a pinned compiler and a build script. Verified end to end against a real service contract: the generated library installs, builds to dist/, and resolves by its published name. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/generators/client_library.go | 65 +++++++++++++++- pkg/generators/client_library_test.go | 108 ++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/pkg/generators/client_library.go b/pkg/generators/client_library.go index c110c4f19..fda872877 100644 --- a/pkg/generators/client_library.go +++ b/pkg/generators/client_library.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -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* @@ -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 { @@ -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 } @@ -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 } diff --git a/pkg/generators/client_library_test.go b/pkg/generators/client_library_test.go index 15db9b0aa..19fa614a6 100644 --- a/pkg/generators/client_library_test.go +++ b/pkg/generators/client_library_test.go @@ -2,6 +2,7 @@ package generators import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -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) + } +} From f943390b190fd49e7ab3390b137afea2adc09fbf Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:45:37 -0400 Subject: [PATCH 2/2] docs(generate): the TypeScript library layout names its entry point and build docs/commands.md listed the generated tree without src/index.ts and said nothing of how the package resolves or builds; it now names the barrel, the dist/ entry point the package exports and ships, and the scaffold's build script. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/commands.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index 7ad23196b..45d9d1df4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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, /_gen/…, /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 ` to distribute it.