From 1273ce0a69d6c231199d88b1e0b8b97e7cebf637 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:38:14 +0400 Subject: [PATCH 01/15] docs(ts): capture protobuf-es transport target output + wire-correctness proof Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + docs/superpowers/notes/es-transport-target.md | 229 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 docs/superpowers/notes/es-transport-target.md diff --git a/.gitignore b/.gitignore index f7b58efe..9c24530e 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ internal/modules/*/internal/infrastructure/persistence/sqlc/ # Temporary test files *.generated + +# Scratch / spike work (throwaway, never committed) +.scratch/ diff --git a/docs/superpowers/notes/es-transport-target.md b/docs/superpowers/notes/es-transport-target.md new file mode 100644 index 00000000..976818ac --- /dev/null +++ b/docs/superpowers/notes/es-transport-target.md @@ -0,0 +1,229 @@ +# protobuf-es TypeScript transport — target output + wire-correctness proof + +Spike for Task 1 of the `protobuf-es` TS runtime mode. This is the **golden reference** the +generator must reproduce in later tasks. Hand-authored, typechecked under `nodenext`, and proven +wire-correct against protojson before any generator code was touched. + +## Environment (resolved) + +- `@bufbuild/protobuf`: **2.12.1** (major **v2** — the `create` / `fromJson` / `toJson` / + `MessageInitShape` / `GenMessage` API is v2; v1 does not have this surface). +- `@bufbuild/protoc-gen-es`: **2.12.1**. +- `typescript`: `^5`. +- `protoc`: libprotoc 32.1 (on PATH). +- Node: v25.9.0. + +## Fixture proto used + +Authored a tiny self-contained 2-message service (`.scratch/es-spike/get_albums.proto`). +The recommended `multi_word_oneof.proto` was **not** used because it lacks a repeated field, which +the zero-value-materialization proof requires. sebuf HTTP annotations were also dropped from the +spike proto: they do not affect the message codec or the `_pb.ts` message import surface (the actual +contract), the hand-authored client hardcodes the path anyway, and keeping the +`import "sebuf/http/annotations.proto"` forces protoc-gen-es to emit an +`import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb"` dependency (plus a +`google/protobuf/descriptor` chain) that is pure noise for this proof. + +```proto +syntax = "proto3"; +package spike.library.v1; + +message Album { + string id = 1; + string title = 2; +} +message GetAlbumsRequest { + string query = 1; + int64 limit = 2; // -> bigint in TS, "5" (string) in protojson + repeated string genres = 3; +} +message GetAlbumsResponse { + repeated Album albums = 1; // zero-value materialization target + int32 total = 2; +} +service AlbumService { + rpc GetAlbums(GetAlbumsRequest) returns (GetAlbumsResponse) {} +} +``` + +Generated with: + +``` +protoc --plugin=protoc-gen-es=./node_modules/.bin/protoc-gen-es \ + --es_out=. --es_opt=target=ts --proto_path=. get_albums.proto +``` + +## `_pb.ts` import surface protoc-gen-es produced (the contract for the generator) + +Header banner: `// @generated by protoc-gen-es v2.12.1 with parameter "target=ts"`. + +Imports emitted by protoc-gen-es (`target=ts`): + +```ts +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; +``` + +Exported symbols per message ``: + +- `export type = Message<"."> & { ...fields }` — the message interface. Field type + mapping observed: `string -> string`, `int64 -> bigint`, `int32 -> number`, + `repeated string -> string[]`, `repeated -> []`. +- `export const Schema: GenMessage<> = messageDesc(file_, )` — the runtime + schema handle passed to `create` / `fromJson` / `toJson`. + +Plus one `export const file_: GenFile = fileDesc("")` per file and one +`export const : GenService<{...}>` per service (the service export is unused by the sebuf +transport client — the client is hand-authored — but protoc-gen-es always emits it). + +So for this fixture the client imports: `GetAlbumsRequestSchema`, `GetAlbumsResponseSchema` +(values), and `GetAlbumsResponse` (type) from `./get_albums_pb.js`. + +## Target sebuf transport client shape (VALIDATED — the exact contract) + +Layout is modules-mode: message schemas in `_pb.js`, shared `errors.js` a sibling module +(spike used a flat `./errors.js`; the real generator emits the relative depth to the root +`errors.js`, e.g. `../../../errors.js`). All relative imports carry `.js` extensions (Node ESM / +nodenext). + +```ts +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: get_albums.proto + +import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { ApiError, ValidationError } from "./errors.js"; +import { GetAlbumsRequestSchema, GetAlbumsResponseSchema } from "./get_albums_pb.js"; +import type { GetAlbumsResponse } from "./get_albums_pb.js"; + +export interface AlbumServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface AlbumServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class AlbumServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: AlbumServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async getAlbums(req: MessageInitShape, options?: AlbumServiceCallOptions): Promise { + const url = this.baseURL + "/api/album/v1/get-albums"; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(toJson(GetAlbumsRequestSchema, create(GetAlbumsRequestSchema, req))), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return fromJson(GetAlbumsResponseSchema, await resp.json(), { ignoreUnknownFields: true }); + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} +``` + +### Design decisions LOCKED here (must be honored by the generator) + +- Request param is `MessageInitShapeSchema>`. Consumers pass plain object literals; + the client calls `create(Schema, req)` then `toJson(Schema, ...)`. int64 fields accept + `bigint` / `number` / string in the init shape and serialize to a protojson string. +- Response decode is `fromJson(Schema, await resp.json(), { ignoreUnknownFields: true })`. + The `ignoreUnknownFields: true` option is **MANDATORY** (forward-compat with server-added fields). +- `errors.ts` stays exactly as today (shared `ApiError` / `ValidationError`, copied verbatim from + `internal/tsclientgen/testdata/golden/errors.ts`), imported with a `.js` extension. +- All relative imports use `.js` extensions (nodenext). +- Headers / method / path / `AbortSignal` / `baseURL.replace(/\/+$/, "")` / `defaultHeaders` spread + order are identical to the current hand-rolled client + (`internal/tsclientgen/testdata/golden/multi_word_oneof_client.ts`). The only body/response + changes vs. the current client are the codec calls (`toJson(create(...))` on send, + `fromJson(..., {ignoreUnknownFields:true})` on receive) replacing the raw + `JSON.stringify(req)` / `await resp.json() as T`. + +## Proof 1 — typechecks under nodenext (tsc --noEmit) + +`tsconfig.json`: `module` + `moduleResolution` = `nodenext`, `strict: true`, `noEmit: true`, +`skipLibCheck: true`, `@bufbuild/protobuf` resolvable in `node_modules`. + +``` +$ ./node_modules/.bin/tsc --noEmit -p tsconfig.json +=== tsc noEmit EXIT 0 === +``` + +PASS — zero diagnostics. + +## Proof 2 — wire-correct: canonical JSON with omitted zero-values decodes to a complete object + +Compiled the spike to ESM JS in `dist/` (raw `node` on `.ts` under nodenext is unreliable; compile +then run), then: + +``` +$ node --input-type=module -e ' +import { fromJson } from "@bufbuild/protobuf"; +import { GetAlbumsResponseSchema } from "./dist/get_albums_pb.js"; +const m = fromJson(GetAlbumsResponseSchema, {}, { ignoreUnknownFields: true }); +console.log(Array.isArray(m.albums) ? "defaults filled: albums=[] (length " + m.albums.length + "), total=" + m.total : "FAIL"); +' +defaults filled: albums=[] (length 0), total=0 +``` + +PASS — `fromJson({}, { ignoreUnknownFields: true })` materializes the omitted `repeated albums` as +`[]` and omitted `int32 total` as `0`. This is the whole point: the codec fills proto3 zero-values +that protojson omits, so consumers get a complete object instead of `undefined` fields. + +### Bonus — request encode round-trip (int64 -> protojson string) + +``` +$ node --input-type=module -e ' +import { create, toJson } from "@bufbuild/protobuf"; +import { GetAlbumsRequestSchema } from "./dist/get_albums_pb.js"; +console.log(JSON.stringify(toJson(GetAlbumsRequestSchema, create(GetAlbumsRequestSchema, { query: "jazz", limit: 5n, genres: ["a","b"] })))); +' +{"query":"jazz","limit":"5","genres":["a","b"]} +``` + +Confirms `create` + `toJson` on a plain init object produces canonical protojson (int64 `5n` -> the +string `"5"`). + +## Notes for the generator tasks + +- The scratch build under `.scratch/es-spike/` is throwaway and git-ignored (`.scratch/` added to + `.gitignore`). Only this note is committed. +- The generator must emit the `_pb.ts` via protoc-gen-es (`target=ts`) and, separately, the + `_client.ts` matching the shape above. Message-type import specifier changes from + `./.js` (current hand-rolled types module) to `./_pb.js` (protoc-gen-es output). +- `errors.ts` is unchanged and remains the single shared root module. From a6d4a57f7dc4f1330a369f6cbf546bf61673d26e Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:57:41 +0400 Subject: [PATCH 02/15] feat(ts): add ts_runtime=protobuf-es option (plumbing, no behavior yet) Co-Authored-By: Claude Opus 4.8 --- cmd/protoc-gen-ts-client/main.go | 8 +++- cmd/protoc-gen-ts-server/main.go | 8 +++- internal/tsclientgen/generator.go | 6 ++- internal/tsclientgen/inprocess_golden_test.go | 6 ++- internal/tsclientgen/modules.go | 4 +- internal/tscommon/imports.go | 3 ++ internal/tscommon/modules.go | 7 ++-- internal/tscommon/runtime.go | 38 +++++++++++++++++++ internal/tscommon/runtime_test.go | 12 ++++++ internal/tsservergen/generator.go | 6 ++- internal/tsservergen/inprocess_golden_test.go | 8 ++-- internal/tsservergen/modules.go | 4 +- 12 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 internal/tscommon/runtime.go create mode 100644 internal/tscommon/runtime_test.go diff --git a/cmd/protoc-gen-ts-client/main.go b/cmd/protoc-gen-ts-client/main.go index a8554c41..a8123762 100644 --- a/cmd/protoc-gen-ts-client/main.go +++ b/cmd/protoc-gen-ts-client/main.go @@ -5,14 +5,20 @@ import ( "google.golang.org/protobuf/types/pluginpb" "github.com/SebastienMelki/sebuf/internal/tsclientgen" + "github.com/SebastienMelki/sebuf/internal/tscommon" ) func main() { options := protogen.Options{} + // protogen.Options.New errors on any parameter it doesn't recognize unless a + // ParamFunc is registered. Accept ts_runtime here (it's parsed from the raw + // parameter string below) so it isn't rejected as an unknown parameter. + options.ParamFunc = func(_, _ string) error { return nil } options.Run(func(plugin *protogen.Plugin) error { plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) - gen := tsclientgen.New(plugin) + runtime := tscommon.ParseMessageRuntime(plugin.Request.GetParameter()) + gen := tsclientgen.New(plugin, runtime) return gen.Generate() }) } diff --git a/cmd/protoc-gen-ts-server/main.go b/cmd/protoc-gen-ts-server/main.go index 14cdc021..fb2c3a66 100644 --- a/cmd/protoc-gen-ts-server/main.go +++ b/cmd/protoc-gen-ts-server/main.go @@ -4,15 +4,21 @@ import ( "google.golang.org/protobuf/compiler/protogen" "google.golang.org/protobuf/types/pluginpb" + "github.com/SebastienMelki/sebuf/internal/tscommon" "github.com/SebastienMelki/sebuf/internal/tsservergen" ) func main() { options := protogen.Options{} + // protogen.Options.New errors on any parameter it doesn't recognize unless a + // ParamFunc is registered. Accept ts_runtime here (it's parsed from the raw + // parameter string below) so it isn't rejected as an unknown parameter. + options.ParamFunc = func(_, _ string) error { return nil } options.Run(func(plugin *protogen.Plugin) error { plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) - gen := tsservergen.New(plugin) + runtime := tscommon.ParseMessageRuntime(plugin.Request.GetParameter()) + gen := tsservergen.New(plugin, runtime) return gen.Generate() }) } diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index bb4db9e9..be2a2c49 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -15,11 +15,13 @@ type Generator struct { // ctx carries the emission state (self module + import tracker) for the // service file currently being written. ctx *tscommon.EmitContext + // runtime selects the TypeScript message representation. + runtime tscommon.MessageRuntime } // New creates a new TypeScript client generator. -func New(plugin *protogen.Plugin) *Generator { - return &Generator{plugin: plugin} +func New(plugin *protogen.Plugin, runtime tscommon.MessageRuntime) *Generator { + return &Generator{plugin: plugin, runtime: runtime} } // Generate emits one canonical type module per proto file, a shared errors diff --git a/internal/tsclientgen/inprocess_golden_test.go b/internal/tsclientgen/inprocess_golden_test.go index a091f12f..cd9cfbcd 100644 --- a/internal/tsclientgen/inprocess_golden_test.go +++ b/internal/tsclientgen/inprocess_golden_test.go @@ -11,6 +11,8 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/descriptorpb" "google.golang.org/protobuf/types/pluginpb" + + "github.com/SebastienMelki/sebuf/internal/tscommon" ) // TestTSClientGenInProcess drives the client generator in-process against the @@ -35,7 +37,7 @@ func TestTSClientGenInProcess(t *testing.T) { t.Run(tc.name, func(t *testing.T) { plugin := buildInProcessPlugin(t, protoDir, projectRoot, tc.protoFiles) - gen := New(plugin) + gen := New(plugin, tscommon.MessageRuntimeHandRolled) if genErr := gen.Generate(); genErr != nil { t.Fatalf("Generate() failed: %v", genErr) } @@ -61,7 +63,7 @@ func TestTSClientGenInProcessReservedName(t *testing.T) { protoDir := filepath.Join(baseDir, "testdata", "proto") plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{"reserved_name.proto"}) - genErr := New(plugin).Generate() + genErr := New(plugin, tscommon.MessageRuntimeHandRolled).Generate() if genErr == nil { t.Fatal("expected Generate() to fail for reserved message name, but it succeeded") } diff --git a/internal/tsclientgen/modules.go b/internal/tsclientgen/modules.go index ac090e92..5fd98a62 100644 --- a/internal/tsclientgen/modules.go +++ b/internal/tsclientgen/modules.go @@ -11,7 +11,7 @@ import ( // its request/response types and the error helpers, then a per-package barrel // (index.ts) re-exporting each package directory's modules. func (g *Generator) generateModules() error { - moduleFiles, err := tscommon.EmitSharedModules(g.plugin) + moduleFiles, err := tscommon.EmitSharedModules(g.plugin, g.runtime) if err != nil { return err } @@ -33,7 +33,7 @@ func (g *Generator) emitClientModule(file *protogen.File) string { module := file.GeneratedFilenamePrefix + "_client" gf := g.plugin.NewGeneratedFile(module+".ts", "") tracker := tscommon.NewImportTracker() - g.ctx = &tscommon.EmitContext{SelfModule: module, Imports: tracker} + g.ctx = &tscommon.EmitContext{SelfModule: module, Imports: tracker, MessageRuntime: g.runtime} defer func() { g.ctx = nil }() var body []string diff --git a/internal/tscommon/imports.go b/internal/tscommon/imports.go index 8874f31f..cfea33c1 100644 --- a/internal/tscommon/imports.go +++ b/internal/tscommon/imports.go @@ -160,6 +160,9 @@ func (t *ImportTracker) Render(p Printer) { type EmitContext struct { SelfModule string // extensionless module path of the file being written Imports *ImportTracker + // MessageRuntime selects the TypeScript message representation. The zero + // value (MessageRuntimeHandRolled) preserves the historical default. + MessageRuntime MessageRuntime } func (c *EmitContext) modules() bool { diff --git a/internal/tscommon/modules.go b/internal/tscommon/modules.go index 20e737b0..d6b4045c 100644 --- a/internal/tscommon/modules.go +++ b/internal/tscommon/modules.go @@ -78,7 +78,7 @@ func CheckReservedNames(ms *MessageSet) error { // client and server against the same output directory yields consistent type // modules. It returns the output-relative filenames (ending in ".ts") of the // type modules it emitted, so callers can fold them into per-package barrels. -func EmitSharedModules(plugin *protogen.Plugin) ([]string, error) { +func EmitSharedModules(plugin *protogen.Plugin, runtime MessageRuntime) ([]string, error) { global := CollectAllServiceMessages(plugin) if err := CheckReservedNames(global); err != nil { return nil, err @@ -88,7 +88,7 @@ func EmitSharedModules(plugin *protogen.Plugin) ([]string, error) { enumsBySrc := global.EnumsBySourceFile() var emitted []string for _, src := range sortedSourceFiles(msgsBySrc, enumsBySrc) { - if name := emitTypeModule(plugin, src, msgsBySrc[src], enumsBySrc[src]); name != "" { + if name := emitTypeModule(plugin, src, msgsBySrc[src], enumsBySrc[src], runtime); name != "" { emitted = append(emitted, name) } } @@ -158,6 +158,7 @@ func emitTypeModule( srcPath string, msgs []*protogen.Message, enums []*protogen.Enum, + runtime MessageRuntime, ) string { if len(msgs) == 0 && len(enums) == 0 { return "" @@ -165,7 +166,7 @@ func emitTypeModule( module := ModuleForFile(srcPath) gf := plugin.NewGeneratedFile(module+".ts", "") tracker := NewImportTracker() - ctx := &EmitContext{SelfModule: module, Imports: tracker} + ctx := &EmitContext{SelfModule: module, Imports: tracker, MessageRuntime: runtime} var body []string bp := BufferedPrinter(&body) diff --git a/internal/tscommon/runtime.go b/internal/tscommon/runtime.go new file mode 100644 index 00000000..1deff26a --- /dev/null +++ b/internal/tscommon/runtime.go @@ -0,0 +1,38 @@ +package tscommon + +import "strings" + +// MessageRuntime selects how generated TypeScript represents protobuf messages. +type MessageRuntime int + +const ( + // MessageRuntimeHandRolled emits hand-rolled TypeScript interfaces and plain + // JSON (de)serialization. It is the zero value and the historical default. + MessageRuntimeHandRolled MessageRuntime = iota + // MessageRuntimeES emits code that targets the protobuf-es runtime. + MessageRuntimeES +) + +// parseMessageRuntime scans a comma-separated protoc plugin parameter string +// (as passed via CodeGeneratorRequest.parameter) for a ts_runtime option and +// returns the selected runtime. Unrecognized or absent values default to +// MessageRuntimeHandRolled. +func parseMessageRuntime(param string) MessageRuntime { + for _, part := range strings.Split(param, ",") { + part = strings.TrimSpace(part) + name, value, found := strings.Cut(part, "=") + if !found || name != "ts_runtime" { + continue + } + if value == "protobuf-es" { + return MessageRuntimeES + } + } + return MessageRuntimeHandRolled +} + +// ParseMessageRuntime is the exported entry point for parsing the ts_runtime +// plugin option from a raw parameter string. +func ParseMessageRuntime(param string) MessageRuntime { + return parseMessageRuntime(param) +} diff --git a/internal/tscommon/runtime_test.go b/internal/tscommon/runtime_test.go new file mode 100644 index 00000000..d9cabd11 --- /dev/null +++ b/internal/tscommon/runtime_test.go @@ -0,0 +1,12 @@ +package tscommon + +import "testing" + +func TestParseTSRuntimeOption(t *testing.T) { + if got := parseMessageRuntime(""); got != MessageRuntimeHandRolled { + t.Fatalf("default = %v, want hand-rolled", got) + } + if got := parseMessageRuntime("ts_runtime=protobuf-es"); got != MessageRuntimeES { + t.Fatalf("es = %v, want protobuf-es", got) + } +} diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index c3e343f1..079c134e 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -19,11 +19,13 @@ type Generator struct { // ctx carries the emission state (self module + import tracker) for the // service file currently being written. ctx *tscommon.EmitContext + // runtime selects the TypeScript message representation. + runtime tscommon.MessageRuntime } // New creates a new TypeScript server generator. -func New(plugin *protogen.Plugin) *Generator { - return &Generator{plugin: plugin} +func New(plugin *protogen.Plugin, runtime tscommon.MessageRuntime) *Generator { + return &Generator{plugin: plugin, runtime: runtime} } // Generate emits one canonical type module per proto file, a shared errors diff --git a/internal/tsservergen/inprocess_golden_test.go b/internal/tsservergen/inprocess_golden_test.go index 431c0610..5594e502 100644 --- a/internal/tsservergen/inprocess_golden_test.go +++ b/internal/tsservergen/inprocess_golden_test.go @@ -11,6 +11,8 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/descriptorpb" "google.golang.org/protobuf/types/pluginpb" + + "github.com/SebastienMelki/sebuf/internal/tscommon" ) // TestTSServerGenInProcess drives the server generator in-process against the @@ -35,7 +37,7 @@ func TestTSServerGenInProcess(t *testing.T) { t.Run(tc.name, func(t *testing.T) { plugin := buildInProcessPlugin(t, protoDir, projectRoot, tc.protoFiles) - gen := New(plugin) + gen := New(plugin, tscommon.MessageRuntimeHandRolled) if genErr := gen.Generate(); genErr != nil { t.Fatalf("Generate() failed: %v", genErr) } @@ -80,7 +82,7 @@ func TestTSServerGenInProcessValidationErrors(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{tc.protoFile}) - genErr := New(plugin).Generate() + genErr := New(plugin, tscommon.MessageRuntimeHandRolled).Generate() if genErr == nil { t.Fatalf("expected Generate() to fail for %s, but it succeeded", tc.protoFile) } @@ -107,7 +109,7 @@ func TestTSServerGenInProcessReservedName(t *testing.T) { protoDir := filepath.Join(baseDir, "testdata", "proto") plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{"reserved_name.proto"}) - genErr := New(plugin).Generate() + genErr := New(plugin, tscommon.MessageRuntimeHandRolled).Generate() if genErr == nil { t.Fatal("expected Generate() to fail for reserved message name, but it succeeded") } diff --git a/internal/tsservergen/modules.go b/internal/tsservergen/modules.go index fc2ad26e..68ca0d4f 100644 --- a/internal/tsservergen/modules.go +++ b/internal/tsservergen/modules.go @@ -11,7 +11,7 @@ import ( // its request/response types and the error helpers, then a per-package barrel // (index.ts) re-exporting each package directory's modules. func (g *Generator) generateModules() error { - moduleFiles, err := tscommon.EmitSharedModules(g.plugin) + moduleFiles, err := tscommon.EmitSharedModules(g.plugin, g.runtime) if err != nil { return err } @@ -37,7 +37,7 @@ func (g *Generator) emitServerModule(file *protogen.File) (string, error) { module := file.GeneratedFilenamePrefix + "_server" gf := g.plugin.NewGeneratedFile(module+".ts", "") tracker := tscommon.NewImportTracker() - g.ctx = &tscommon.EmitContext{SelfModule: module, Imports: tracker} + g.ctx = &tscommon.EmitContext{SelfModule: module, Imports: tracker, MessageRuntime: g.runtime} defer func() { g.ctx = nil }() var body []string From e576057e746bab525f9064bcf948068384b70ce1 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:21:57 +0300 Subject: [PATCH 03/15] refactor(ts): validate ts_runtime param and dedupe parser Reject unknown parameter keys and invalid ts_runtime values in the plugin ParamFunc so a typo fails loudly instead of silently falling back to hand-rolled output. Collapse the ParseMessageRuntime pass- through wrapper into a single exported function. Co-Authored-By: Claude Opus 4.8 --- cmd/protoc-gen-ts-client/main.go | 17 ++++++++++++++--- cmd/protoc-gen-ts-server/main.go | 17 ++++++++++++++--- internal/tscommon/runtime.go | 14 ++++---------- internal/tscommon/runtime_test.go | 7 +++++-- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/cmd/protoc-gen-ts-client/main.go b/cmd/protoc-gen-ts-client/main.go index a8123762..0bcf1b5e 100644 --- a/cmd/protoc-gen-ts-client/main.go +++ b/cmd/protoc-gen-ts-client/main.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "google.golang.org/protobuf/compiler/protogen" "google.golang.org/protobuf/types/pluginpb" @@ -11,9 +13,18 @@ import ( func main() { options := protogen.Options{} // protogen.Options.New errors on any parameter it doesn't recognize unless a - // ParamFunc is registered. Accept ts_runtime here (it's parsed from the raw - // parameter string below) so it isn't rejected as an unknown parameter. - options.ParamFunc = func(_, _ string) error { return nil } + // ParamFunc is registered. paths/module are handled internally and never + // reach this func, so validate ts_runtime here and reject anything else — + // a bad key or value must fail loudly rather than silently fall back. + options.ParamFunc = func(name, value string) error { + if name != "ts_runtime" { + return fmt.Errorf("unknown parameter %q", name) + } + if value != "protobuf-es" && value != "hand-rolled" { + return fmt.Errorf("invalid ts_runtime %q (want protobuf-es or hand-rolled)", value) + } + return nil + } options.Run(func(plugin *protogen.Plugin) error { plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) diff --git a/cmd/protoc-gen-ts-server/main.go b/cmd/protoc-gen-ts-server/main.go index fb2c3a66..07048e61 100644 --- a/cmd/protoc-gen-ts-server/main.go +++ b/cmd/protoc-gen-ts-server/main.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "google.golang.org/protobuf/compiler/protogen" "google.golang.org/protobuf/types/pluginpb" @@ -11,9 +13,18 @@ import ( func main() { options := protogen.Options{} // protogen.Options.New errors on any parameter it doesn't recognize unless a - // ParamFunc is registered. Accept ts_runtime here (it's parsed from the raw - // parameter string below) so it isn't rejected as an unknown parameter. - options.ParamFunc = func(_, _ string) error { return nil } + // ParamFunc is registered. paths/module are handled internally and never + // reach this func, so validate ts_runtime here and reject anything else — + // a bad key or value must fail loudly rather than silently fall back. + options.ParamFunc = func(name, value string) error { + if name != "ts_runtime" { + return fmt.Errorf("unknown parameter %q", name) + } + if value != "protobuf-es" && value != "hand-rolled" { + return fmt.Errorf("invalid ts_runtime %q (want protobuf-es or hand-rolled)", value) + } + return nil + } options.Run(func(plugin *protogen.Plugin) error { plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) diff --git a/internal/tscommon/runtime.go b/internal/tscommon/runtime.go index 1deff26a..41b5029e 100644 --- a/internal/tscommon/runtime.go +++ b/internal/tscommon/runtime.go @@ -13,11 +13,11 @@ const ( MessageRuntimeES ) -// parseMessageRuntime scans a comma-separated protoc plugin parameter string +// ParseMessageRuntime scans a comma-separated protoc plugin parameter string // (as passed via CodeGeneratorRequest.parameter) for a ts_runtime option and -// returns the selected runtime. Unrecognized or absent values default to -// MessageRuntimeHandRolled. -func parseMessageRuntime(param string) MessageRuntime { +// returns the selected runtime. An explicit ts_runtime=hand-rolled, an absent +// option, or any unrecognized value all resolve to MessageRuntimeHandRolled. +func ParseMessageRuntime(param string) MessageRuntime { for _, part := range strings.Split(param, ",") { part = strings.TrimSpace(part) name, value, found := strings.Cut(part, "=") @@ -30,9 +30,3 @@ func parseMessageRuntime(param string) MessageRuntime { } return MessageRuntimeHandRolled } - -// ParseMessageRuntime is the exported entry point for parsing the ts_runtime -// plugin option from a raw parameter string. -func ParseMessageRuntime(param string) MessageRuntime { - return parseMessageRuntime(param) -} diff --git a/internal/tscommon/runtime_test.go b/internal/tscommon/runtime_test.go index d9cabd11..49dd7582 100644 --- a/internal/tscommon/runtime_test.go +++ b/internal/tscommon/runtime_test.go @@ -3,10 +3,13 @@ package tscommon import "testing" func TestParseTSRuntimeOption(t *testing.T) { - if got := parseMessageRuntime(""); got != MessageRuntimeHandRolled { + if got := ParseMessageRuntime(""); got != MessageRuntimeHandRolled { t.Fatalf("default = %v, want hand-rolled", got) } - if got := parseMessageRuntime("ts_runtime=protobuf-es"); got != MessageRuntimeES { + if got := ParseMessageRuntime("ts_runtime=hand-rolled"); got != MessageRuntimeHandRolled { + t.Fatalf("explicit hand-rolled = %v, want hand-rolled", got) + } + if got := ParseMessageRuntime("ts_runtime=protobuf-es"); got != MessageRuntimeES { t.Fatalf("es = %v, want protobuf-es", got) } } From 334464abd6b979b4ea82e8cf71c2677607c8e295 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:45:21 +0300 Subject: [PATCH 04/15] feat(ts): generate protobuf-es transport client (fromJson/toJson, ignoreUnknownFields) Co-Authored-By: Claude Opus 4.8 --- internal/tsclientgen/generator.go | 46 ++++-- internal/tsclientgen/golden_test.go | 98 +++++++++++++ .../tsclientgen/testdata/golden/es/errors.ts | 29 ++++ .../golden/es/multi_word_oneof_client.ts | 69 +++++++++ .../testdata/golden/es/multi_word_oneof_pb.ts | 118 +++++++++++++++ internal/tscommon/imports.go | 134 +++++++++++++++--- internal/tscommon/modules.go | 16 ++- 7 files changed, 471 insertions(+), 39 deletions(-) create mode 100644 internal/tsclientgen/testdata/golden/es/errors.ts create mode 100644 internal/tsclientgen/testdata/golden/es/multi_word_oneof_client.ts create mode 100644 internal/tsclientgen/testdata/golden/es/multi_word_oneof_pb.ts diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index be2a2c49..8815899b 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -1,6 +1,7 @@ package tsclientgen import ( + "fmt" "net/http" "google.golang.org/protobuf/compiler/protogen" @@ -219,8 +220,21 @@ func (g *Generator) generateRPCMethod(p printer, service *protogen.Service, meth return } - inputType := g.ctx.RefMessage(method.Input) - outputType := g.resolveOutputType(method) + es := g.ctx.MessageRuntime == tscommon.MessageRuntimeES + + var inputType, outputType, reqSchema, resSchema string + if es { + // protobuf-es mode: consumers pass a plain init shape; the client routes + // the request through create/toJson and decodes the response with fromJson. + reqSchema = g.ctx.RefMessageSchema(method.Input) + resSchema = g.ctx.RefMessageSchema(method.Output) + inputType = "MessageInitShape" + outputType = g.ctx.RefMessagePb(method.Output) + g.ctx.NeedProtobufES() + } else { + inputType = g.ctx.RefMessage(method.Input) + outputType = g.resolveOutputType(method) + } tsMethodName := annotations.LowerFirst(cfg.methodName) @@ -235,10 +249,10 @@ func (g *Generator) generateRPCMethod(p printer, service *protogen.Service, meth g.generateHeaderMerging(p, service, method) // Build fetch options - g.generateFetchCall(p, cfg) + g.generateFetchCall(p, cfg, reqSchema) // Handle response - g.generateResponseHandling(p, method) + g.generateResponseHandling(p, outputType, resSchema) p(" }") p("") @@ -434,13 +448,19 @@ func (g *Generator) generateHeaderMerging(p printer, service *protogen.Service, p("") } -// generateFetchCall generates the fetch invocation. -func (g *Generator) generateFetchCall(p printer, cfg *rpcMethodConfig) { +// generateFetchCall generates the fetch invocation. In protobuf-es mode +// (reqSchema non-empty) the request body is encoded through create/toJson; +// otherwise the request object is serialized directly. +func (g *Generator) generateFetchCall(p printer, cfg *rpcMethodConfig, reqSchema string) { if cfg.hasBody { + body := "JSON.stringify(req)" + if reqSchema != "" { + body = fmt.Sprintf("JSON.stringify(toJson(%s, create(%s, req)))", reqSchema, reqSchema) + } p(" const resp = await this.fetchFn(url, {") p(` method: "%s",`, cfg.httpMethod) p(" headers,") - p(" body: JSON.stringify(req),") + p(" body: %s,", body) p(" signal: options?.signal,") p(" });") } else { @@ -453,14 +473,18 @@ func (g *Generator) generateFetchCall(p printer, cfg *rpcMethodConfig) { p("") } -// generateResponseHandling generates response parsing and error handling. -func (g *Generator) generateResponseHandling(p printer, method *protogen.Method) { - outputType := g.resolveOutputType(method) - +// generateResponseHandling generates response parsing and error handling. In +// protobuf-es mode (resSchema non-empty) the response is decoded through fromJson +// with ignoreUnknownFields (forward-compat); otherwise it is raw-cast. +func (g *Generator) generateResponseHandling(p printer, outputType, resSchema string) { p(" if (!resp.ok) {") p(" return this.handleError(resp);") p(" }") p("") + if resSchema != "" { + p(" return fromJson(%s, await resp.json(), { ignoreUnknownFields: true });", resSchema) + return + } p(" return await resp.json() as %s;", outputType) } diff --git a/internal/tsclientgen/golden_test.go b/internal/tsclientgen/golden_test.go index 063ed0d2..95431d8e 100644 --- a/internal/tsclientgen/golden_test.go +++ b/internal/tsclientgen/golden_test.go @@ -172,6 +172,104 @@ func TestTSClientGenGoldenFiles(t *testing.T) { } } +// TestTSClientGenESGoldenFiles tests protobuf-es transport client generation +// (ts_runtime=protobuf-es) against golden files. It runs protoc twice into one +// output dir: once with protoc-gen-es (emitting _pb.ts message schemas) +// and once with the sebuf ts-client plugin in es mode (emitting the transport +// client that routes every request/response through create/toJson/fromJson). +// Every emitted .ts file is compared against testdata/golden/es/. +// +// To update golden files after intentional changes (protoc-gen-es must be on PATH): +// +// UPDATE_GOLDEN=1 go test -run TestTSClientGenESGoldenFiles +func TestTSClientGenESGoldenFiles(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping golden file tests") + } + esPluginPath, esErr := exec.LookPath("protoc-gen-es") + if esErr != nil { + t.Skip("protoc-gen-es not found, skipping protobuf-es golden file tests") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + goldenDir := filepath.Join(baseDir, "testdata", "golden", "es") + + if mkdirErr := os.MkdirAll(goldenDir, 0o755); mkdirErr != nil { + t.Fatalf("Failed to create golden directory: %v", mkdirErr) + } + + pluginPath := filepath.Join(projectRoot, "bin", "protoc-gen-ts-client") + if _, buildStatErr := os.Stat(pluginPath); os.IsNotExist(buildStatErr) { + buildCmd := exec.Command("make", "build") + buildCmd.Dir = projectRoot + if buildErr := buildCmd.Run(); buildErr != nil { + t.Fatalf("Failed to build plugin: %v", buildErr) + } + } + + updateGolden := os.Getenv("UPDATE_GOLDEN") == "1" + + // Smallest fixture exercising request-body encode + response decode. + protoFile := "multi_word_oneof.proto" + if _, statErr := os.Stat(filepath.Join(protoDir, protoFile)); os.IsNotExist(statErr) { + t.Fatalf("Proto file not found: %s", protoFile) + } + + outDir := t.TempDir() + protoPaths := []string{"--proto_path=" + protoDir, "--proto_path=" + filepath.Join(projectRoot, "proto")} + + // Pass 1: protoc-gen-es emits _pb.ts (imported by the client). + esArgs := []string{ + "--plugin=protoc-gen-es=" + esPluginPath, + "--es_out=" + outDir, + "--es_opt=target=ts,import_extension=js", + } + esArgs = append(esArgs, protoPaths...) + esArgs = append(esArgs, protoFile) + esCmd := exec.Command("protoc", esArgs...) + esCmd.Dir = protoDir + var esStderr bytes.Buffer + esCmd.Stderr = &esStderr + if runErr := esCmd.Run(); runErr != nil { + t.Fatalf("protoc (protoc-gen-es) failed: %v\nstderr: %s", runErr, esStderr.String()) + } + + // Pass 2: sebuf ts-client in protobuf-es mode emits the transport client. + clientArgs := []string{ + "--plugin=protoc-gen-ts-client=" + pluginPath, + "--ts-client_out=" + outDir, + "--ts-client_opt=paths=source_relative,ts_runtime=protobuf-es", + } + clientArgs = append(clientArgs, protoPaths...) + clientArgs = append(clientArgs, protoFile) + clientCmd := exec.Command("protoc", clientArgs...) + clientCmd.Dir = protoDir + var clientStderr bytes.Buffer + clientCmd.Stderr = &clientStderr + if runErr := clientCmd.Run(); runErr != nil { + t.Fatalf("protoc (ts-client) failed: %v\nstderr: %s", runErr, clientStderr.String()) + } + + for _, rel := range generatedTSFiles(t, outDir) { + generatedContent, readErr := os.ReadFile(filepath.Join(outDir, rel)) + if readErr != nil { + t.Fatalf("Failed to read generated file %s: %v", rel, readErr) + } + goldenPath := filepath.Join(goldenDir, rel) + if updateGolden { + updateGoldenFile(t, goldenPath, generatedContent) + continue + } + compareGoldenFile(t, rel, goldenPath, generatedContent) + } +} + // generatedTSFiles returns the relative paths of every .ts file under dir, sorted. func generatedTSFiles(t *testing.T, dir string) []string { t.Helper() diff --git a/internal/tsclientgen/testdata/golden/es/errors.ts b/internal/tsclientgen/testdata/golden/es/errors.ts new file mode 100644 index 00000000..991abd2c --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/errors.ts @@ -0,0 +1,29 @@ +// Code generated by sebuf. DO NOT EDIT. + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + diff --git a/internal/tsclientgen/testdata/golden/es/multi_word_oneof_client.ts b/internal/tsclientgen/testdata/golden/es/multi_word_oneof_client.ts new file mode 100644 index 00000000..f45c52ac --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/multi_word_oneof_client.ts @@ -0,0 +1,69 @@ +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: multi_word_oneof.proto + +import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { ApiError, ValidationError } from "./errors.js"; +import { MultiWordEventSchema } from "./multi_word_oneof_pb.js"; +import type { MultiWordEvent } from "./multi_word_oneof_pb.js"; + +export interface MultiWordOneofServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface MultiWordOneofServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class MultiWordOneofServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: MultiWordOneofServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async testMultiWordEvent(req: MessageInitShape, options?: MultiWordOneofServiceCallOptions): Promise { + let path = "/api/v1/events/multi-word"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(toJson(MultiWordEventSchema, create(MultiWordEventSchema, req))), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return fromJson(MultiWordEventSchema, await resp.json(), { ignoreUnknownFields: true }); + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/internal/tsclientgen/testdata/golden/es/multi_word_oneof_pb.ts b/internal/tsclientgen/testdata/golden/es/multi_word_oneof_pb.ts new file mode 100644 index 00000000..4c794a67 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/multi_word_oneof_pb.ts @@ -0,0 +1,118 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file multi_word_oneof.proto (package testdata.multi_word_oneof, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file multi_word_oneof.proto. + */ +export const file_multi_word_oneof: GenFile = /*@__PURE__*/ + fileDesc("ChZtdWx0aV93b3JkX29uZW9mLnByb3RvEhl0ZXN0ZGF0YS5tdWx0aV93b3JkX29uZW9mIhsKC1RleHRDb250ZW50EgwKBGJvZHkYASABKAkiOgoMSW1hZ2VDb250ZW50EgsKA3VybBgBIAEoCRINCgV3aWR0aBgCIAEoBRIOCgZoZWlnaHQYAyABKAUiqwEKDk11bHRpV29yZEV2ZW50EgoKAmlkGAEgASgJEjoKCGJpZ190ZXh0GAIgASgLMiYudGVzdGRhdGEubXVsdGlfd29yZF9vbmVvZi5UZXh0Q29udGVudEgAEjwKCWJpZ19pbWFnZRgDIAEoCzInLnRlc3RkYXRhLm11bHRpX3dvcmRfb25lb2YuSW1hZ2VDb250ZW50SABCEwoRc3VwZXJfdGl0bGVfaW1hZ2UyrwEKFU11bHRpV29yZE9uZW9mU2VydmljZRKGAQoSVGVzdE11bHRpV29yZEV2ZW50EikudGVzdGRhdGEubXVsdGlfd29yZF9vbmVvZi5NdWx0aVdvcmRFdmVudBopLnRlc3RkYXRhLm11bHRpX3dvcmRfb25lb2YuTXVsdGlXb3JkRXZlbnQiGpq1GBYKEi9ldmVudHMvbXVsdGktd29yZBACGg2itRgJCgcvYXBpL3YxQl1aW2dpdGh1Yi5jb20vU2ViYXN0aWVuTWVsa2kvc2VidWYvaW50ZXJuYWwvdHNjbGllbnRnZW4vdGVzdGRhdGEvbXVsdGl3b3Jkb25lb2Y7bXVsdGl3b3Jkb25lb2ZiBnByb3RvMw", [file_sebuf_http_annotations]); + +/** + * Variant message types + * + * @generated from message testdata.multi_word_oneof.TextContent + */ +export type TextContent = Message<"testdata.multi_word_oneof.TextContent"> & { + /** + * @generated from field: string body = 1; + */ + body: string; +}; + +/** + * Describes the message testdata.multi_word_oneof.TextContent. + * Use `create(TextContentSchema)` to create a new message. + */ +export const TextContentSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_multi_word_oneof, 0); + +/** + * @generated from message testdata.multi_word_oneof.ImageContent + */ +export type ImageContent = Message<"testdata.multi_word_oneof.ImageContent"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: int32 width = 2; + */ + width: number; + + /** + * @generated from field: int32 height = 3; + */ + height: number; +}; + +/** + * Describes the message testdata.multi_word_oneof.ImageContent. + * Use `create(ImageContentSchema)` to create a new message. + */ +export const ImageContentSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_multi_word_oneof, 1); + +/** + * Message with a multi-word oneof name and no oneof_config annotation. + * + * The oneof name (super_title_image) is deliberately multi-word: it must + * surface in the generated TypeScript only as the PascalCase union type name + * (MultiWordEventSuperTitleImage) and must NEVER leak as a raw snake_case + * wrapper property. Because the oneof is un-annotated it takes the + * presence-discriminated flat-union path, matching plain protojson. + * + * @generated from message testdata.multi_word_oneof.MultiWordEvent + */ +export type MultiWordEvent = Message<"testdata.multi_word_oneof.MultiWordEvent"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from oneof testdata.multi_word_oneof.MultiWordEvent.super_title_image + */ + superTitleImage: { + /** + * @generated from field: testdata.multi_word_oneof.TextContent big_text = 2; + */ + value: TextContent; + case: "bigText"; + } | { + /** + * @generated from field: testdata.multi_word_oneof.ImageContent big_image = 3; + */ + value: ImageContent; + case: "bigImage"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message testdata.multi_word_oneof.MultiWordEvent. + * Use `create(MultiWordEventSchema)` to create a new message. + */ +export const MultiWordEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_multi_word_oneof, 2); + +/** + * @generated from service testdata.multi_word_oneof.MultiWordOneofService + */ +export const MultiWordOneofService: GenService<{ + /** + * @generated from rpc testdata.multi_word_oneof.MultiWordOneofService.TestMultiWordEvent + */ + testMultiWordEvent: { + methodKind: "unary"; + input: typeof MultiWordEventSchema; + output: typeof MultiWordEventSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_multi_word_oneof, 0); + diff --git a/internal/tscommon/imports.go b/internal/tscommon/imports.go index cfea33c1..4603ab8b 100644 --- a/internal/tscommon/imports.go +++ b/internal/tscommon/imports.go @@ -64,31 +64,39 @@ type importedSymbol struct { // ImportTracker collects the cross-module imports a single generated file needs // and renders the import block. It is only used in modules mode. type ImportTracker struct { - typeImports map[string][]importedSymbol // specifier -> imported type symbols - aliasOf map[string]string // "spec\x00symbol" -> local alias - usedAlias map[string]string // local alias -> owning "spec\x00symbol" - errorSyms map[string]bool // error helpers referenced (value import) - errorsSpec string + typeImports map[string][]importedSymbol // specifier -> imported type-only symbols + valueImports map[string][]importedSymbol // specifier -> imported value symbols + aliasOf map[string]string // "spec\x00symbol" -> local alias + usedAlias map[string]string // local alias -> owning "spec\x00symbol" + errorSyms map[string]bool // error helpers referenced (value import) + errorsSpec string + // needProtobufES records that the file references the protobuf-es runtime + // helpers (create/fromJson/toJson/MessageInitShape). + needProtobufES bool } // NewImportTracker returns an empty tracker. func NewImportTracker() *ImportTracker { return &ImportTracker{ - typeImports: map[string][]importedSymbol{}, - aliasOf: map[string]string{}, - usedAlias: map[string]string{}, - errorSyms: map[string]bool{}, + typeImports: map[string][]importedSymbol{}, + valueImports: map[string][]importedSymbol{}, + aliasOf: map[string]string{}, + usedAlias: map[string]string{}, + errorSyms: map[string]bool{}, } } -// NeedType records that `symbol` from module specifier `spec` is referenced and -// returns the local name to use (aliased deterministically on collision). -func (t *ImportTracker) NeedType(spec, symbol string) string { +// assignAlias reserves a deterministic local binding for `symbol` imported from +// `spec`, aliasing on collision. It reports the alias and whether this +// (spec, symbol) pair was seen for the first time (so callers append to their +// import map only once). Type and value imports share one alias namespace +// because they occupy the same module-scope binding namespace in TypeScript. +func (t *ImportTracker) assignAlias(spec, symbol string) (alias string, isNew bool) { key := spec + "\x00" + symbol if a, ok := t.aliasOf[key]; ok { - return a + return a, false } - alias := symbol + alias = symbol if owner, taken := t.usedAlias[alias]; taken && owner != key { for i := 1; ; i++ { cand := fmt.Sprintf("%s_%d", symbol, i) @@ -100,10 +108,37 @@ func (t *ImportTracker) NeedType(spec, symbol string) string { } t.usedAlias[alias] = key t.aliasOf[key] = alias - t.typeImports[spec] = append(t.typeImports[spec], importedSymbol{symbol: symbol, alias: alias}) + return alias, true +} + +// NeedType records that `symbol` from module specifier `spec` is referenced as a +// type-only import and returns the local name to use (aliased deterministically +// on collision). +func (t *ImportTracker) NeedType(spec, symbol string) string { + alias, isNew := t.assignAlias(spec, symbol) + if isNew { + t.typeImports[spec] = append(t.typeImports[spec], importedSymbol{symbol: symbol, alias: alias}) + } + return alias +} + +// NeedValue records that `symbol` from module specifier `spec` is referenced as +// a value import (e.g. a protobuf-es `*Schema` handle) and returns the local +// name to use (aliased deterministically on collision). +func (t *ImportTracker) NeedValue(spec, symbol string) string { + alias, isNew := t.assignAlias(spec, symbol) + if isNew { + t.valueImports[spec] = append(t.valueImports[spec], importedSymbol{symbol: symbol, alias: alias}) + } return alias } +// NeedProtobufESRuntime records that the file references the protobuf-es runtime +// helpers, so Render emits the fixed `@bufbuild/protobuf` import. +func (t *ImportTracker) NeedProtobufESRuntime() { + t.needProtobufES = true +} + // NeedErrors records that the given shared error helpers are referenced, // importing them (as a value import) relative to the given module specifier. func (t *ImportTracker) NeedErrors(spec string, symbols ...string) { @@ -115,15 +150,21 @@ func (t *ImportTracker) NeedErrors(spec string, symbols ...string) { // Empty reports whether no imports were recorded. func (t *ImportTracker) Empty() bool { - return len(t.errorSyms) == 0 && len(t.typeImports) == 0 + return !t.needProtobufES && len(t.errorSyms) == 0 && + len(t.valueImports) == 0 && len(t.typeImports) == 0 } -// Render writes the import block (value import for error helpers, then sorted -// type-only imports). It emits a trailing blank line when anything was written. +// Render writes the import block: the fixed protobuf-es runtime import (es mode +// only), the value import for error helpers, sorted value imports (protobuf-es +// `*Schema` handles), then sorted type-only imports. It emits a trailing blank +// line when anything was written. func (t *ImportTracker) Render(p Printer) { if t.Empty() { return } + if t.needProtobufES { + p(`import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf";`) + } if len(t.errorSyms) > 0 { syms := make([]string, 0, len(t.errorSyms)) for s := range t.errorSyms { @@ -132,13 +173,21 @@ func (t *ImportTracker) Render(p Printer) { sort.Strings(syms) p(`import { %s } from "%s";`, strings.Join(syms, ", "), t.errorsSpec) } - specs := make([]string, 0, len(t.typeImports)) - for s := range t.typeImports { + t.renderImportGroup(p, t.valueImports, false) + t.renderImportGroup(p, t.typeImports, true) + p("") +} + +// renderImportGroup writes one sorted block of imports (by specifier, then by +// symbol). typeOnly selects the `import type { ... }` form. +func (t *ImportTracker) renderImportGroup(p Printer, group map[string][]importedSymbol, typeOnly bool) { + specs := make([]string, 0, len(group)) + for s := range group { specs = append(specs, s) } sort.Strings(specs) for _, spec := range specs { - syms := append([]importedSymbol(nil), t.typeImports[spec]...) + syms := append([]importedSymbol(nil), group[spec]...) sort.Slice(syms, func(i, j int) bool { return syms[i].symbol < syms[j].symbol }) parts := make([]string, 0, len(syms)) for _, is := range syms { @@ -148,9 +197,12 @@ func (t *ImportTracker) Render(p Printer) { parts = append(parts, fmt.Sprintf("%s as %s", is.symbol, is.alias)) } } - p(`import type { %s } from "%s";`, strings.Join(parts, ", "), spec) + if typeOnly { + p(`import type { %s } from "%s";`, strings.Join(parts, ", "), spec) + } else { + p(`import { %s } from "%s";`, strings.Join(parts, ", "), spec) + } } - p("") } // EmitContext threads the module-emission state through the (otherwise @@ -198,6 +250,42 @@ func (c *EmitContext) ref(symbol string, file protoreflect.FileDescriptor) strin return c.Imports.NeedType(RelativeImportSpecifier(c.SelfModule, mod), symbol) } +// pbModuleSpec returns the relative import specifier for the protoc-gen-es +// message module (`_pb.js`) that owns msg, relative to the file being +// written. +func (c *EmitContext) pbModuleSpec(file protoreflect.FileDescriptor) string { + return RelativeImportSpecifier(c.SelfModule, ModuleForFile(file.Path())+"_pb") +} + +// RefMessagePb returns the local TypeScript name for a message type imported +// from its protoc-gen-es `_pb.js` module, recording a type-only import. +// Used in protobuf-es runtime mode where messages come from protoc-gen-es rather +// than the hand-rolled type modules. +func (c *EmitContext) RefMessagePb(msg *protogen.Message) string { + if msg == nil { + return "" + } + return c.Imports.NeedType(c.pbModuleSpec(msg.Desc.ParentFile()), QualifiedTSName(msg.Desc)) +} + +// RefMessageSchema returns the local name for a message's protobuf-es `*Schema` +// runtime handle (imported as a value from its `_pb.js` module) and +// records the value import. The schema is passed to create/toJson/fromJson. +func (c *EmitContext) RefMessageSchema(msg *protogen.Message) string { + if msg == nil { + return "" + } + return c.Imports.NeedValue(c.pbModuleSpec(msg.Desc.ParentFile()), QualifiedTSName(msg.Desc)+"Schema") +} + +// NeedProtobufES records that the file references the protobuf-es runtime +// helpers (create/fromJson/toJson/MessageInitShape). +func (c *EmitContext) NeedProtobufES() { + if c.modules() { + c.Imports.NeedProtobufESRuntime() + } +} + // NeedErrors records that this file references the given shared error helpers. func (c *EmitContext) NeedErrors(symbols ...string) { if !c.modules() || len(symbols) == 0 { diff --git a/internal/tscommon/modules.go b/internal/tscommon/modules.go index d6b4045c..8bb99570 100644 --- a/internal/tscommon/modules.go +++ b/internal/tscommon/modules.go @@ -84,12 +84,18 @@ func EmitSharedModules(plugin *protogen.Plugin, runtime MessageRuntime) ([]strin return nil, err } - msgsBySrc := global.MessagesBySourceFile() - enumsBySrc := global.EnumsBySourceFile() + // In protobuf-es mode the message/enum modules are owned by protoc-gen-es + // (_pb.ts); sebuf must not emit its hand-rolled type modules or they + // would clash. The shared errors module is still sebuf's to emit — the + // generated client imports ApiError/ValidationError from it. var emitted []string - for _, src := range sortedSourceFiles(msgsBySrc, enumsBySrc) { - if name := emitTypeModule(plugin, src, msgsBySrc[src], enumsBySrc[src], runtime); name != "" { - emitted = append(emitted, name) + if runtime != MessageRuntimeES { + msgsBySrc := global.MessagesBySourceFile() + enumsBySrc := global.EnumsBySourceFile() + for _, src := range sortedSourceFiles(msgsBySrc, enumsBySrc) { + if name := emitTypeModule(plugin, src, msgsBySrc[src], enumsBySrc[src], runtime); name != "" { + emitted = append(emitted, name) + } } } emitErrorsModule(plugin) From 8a5ab05927e690331f29e2db7074be59d2a61a45 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:08:24 +0300 Subject: [PATCH 05/15] feat(ts): protobuf-es SSE transport + reconcile nested _pb naming Extend ts_runtime=protobuf-es to server-streaming (SSE) RPCs: the async generator now takes a MessageInitShape request, encodes any request body via create/toJson, and decodes each streamed event through fromJson(Schema, JSON.parse(data), { ignoreUnknownFields: true }) instead of a raw cast, importing schemas/types from protoc-gen-es _pb.js. Derive protoc-gen-es local symbol names via ESQualifiedName (underscore-joined nested names, e.g. Outer_Inner / Outer_InnerSchema) rather than QualifiedTSName, matching @bufbuild/protoc-gen-es v2.12.1 output (verified empirically). Co-Authored-By: Claude Opus 4.8 --- internal/tsclientgen/generator.go | 47 +++- internal/tsclientgen/golden_test.go | 106 +++++---- .../testdata/golden/es/sse_client.ts | 201 +++++++++++++++++ .../tsclientgen/testdata/golden/es/sse_pb.ts | 208 ++++++++++++++++++ internal/tscommon/imports.go | 4 +- internal/tscommon/types.go | 21 ++ 6 files changed, 529 insertions(+), 58 deletions(-) create mode 100644 internal/tsclientgen/testdata/golden/es/sse_client.ts create mode 100644 internal/tsclientgen/testdata/golden/es/sse_pb.ts diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index 8815899b..afe206d8 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -265,8 +265,22 @@ func (g *Generator) generateSSERPCMethod( method *protogen.Method, cfg *rpcMethodConfig, ) { - inputType := g.ctx.RefMessage(method.Input) - outputType := g.resolveOutputType(method) + es := g.ctx.MessageRuntime == tscommon.MessageRuntimeES + + var inputType, outputType, reqSchema, resSchema string + if es { + // protobuf-es mode mirrors the unary path: the request is an init shape + // encoded through create/toJson (when a body is sent), and each streamed + // event is decoded with fromJson. The generator yields the decoded message. + reqSchema = g.ctx.RefMessageSchema(method.Input) + resSchema = g.ctx.RefMessageSchema(method.Output) + inputType = "MessageInitShape" + outputType = g.ctx.RefMessagePb(method.Output) + g.ctx.NeedProtobufES() + } else { + inputType = g.ctx.RefMessage(method.Input) + outputType = g.resolveOutputType(method) + } tsMethodName := annotations.LowerFirst(cfg.methodName) reqParam := cfg.requestParamName() @@ -280,10 +294,10 @@ func (g *Generator) generateSSERPCMethod( g.generateSSEHeaderMerging(p, service, method) // Fetch call - g.generateSSEFetchCall(p, cfg) + g.generateSSEFetchCall(p, cfg, reqSchema) // SSE stream parsing - g.generateSSEStreamParsing(p, outputType) + g.generateSSEStreamParsing(p, outputType, resSchema) p(" }") p("") @@ -316,13 +330,19 @@ func (g *Generator) generateSSEHeaderMerging(p printer, service *protogen.Servic p("") } -// generateSSEFetchCall generates the fetch invocation for SSE. -func (g *Generator) generateSSEFetchCall(p printer, cfg *rpcMethodConfig) { +// generateSSEFetchCall generates the fetch invocation for SSE. In protobuf-es +// mode (reqSchema non-empty) a request body is encoded through create/toJson, +// matching the unary path; otherwise the request object is serialized directly. +func (g *Generator) generateSSEFetchCall(p printer, cfg *rpcMethodConfig, reqSchema string) { if cfg.hasBody { + body := "JSON.stringify(req)" + if reqSchema != "" { + body = fmt.Sprintf("JSON.stringify(toJson(%s, create(%s, req)))", reqSchema, reqSchema) + } p(" const resp = await this.fetchFn(url, {") p(` method: "%s",`, cfg.httpMethod) p(" headers,") - p(" body: JSON.stringify(req),") + p(" body: %s,", body) p(" signal: options?.signal,") p(" });") } else { @@ -340,8 +360,11 @@ func (g *Generator) generateSSEFetchCall(p printer, cfg *rpcMethodConfig) { p("") } -// generateSSEStreamParsing generates the ReadableStream SSE parsing logic. -func (g *Generator) generateSSEStreamParsing(p printer, outputType string) { +// generateSSEStreamParsing generates the ReadableStream SSE parsing logic. In +// protobuf-es mode (resSchema non-empty) each streamed event is decoded through +// fromJson with ignoreUnknownFields (forward-compat, mandatory); otherwise the +// parsed JSON is raw-cast to the output type. +func (g *Generator) generateSSEStreamParsing(p printer, outputType, resSchema string) { p(" const reader = resp.body!.getReader();") p(" const decoder = new TextDecoder();") p(` let buffer = "";`) @@ -356,7 +379,11 @@ func (g *Generator) generateSSEStreamParsing(p printer, outputType string) { p(" for (const line of lines) {") p(` if (line.startsWith("data: ")) {`) p(" const data = line.slice(6);") - p(" yield JSON.parse(data) as %s;", outputType) + if resSchema != "" { + p(" yield fromJson(%s, JSON.parse(data), { ignoreUnknownFields: true });", resSchema) + } else { + p(" yield JSON.parse(data) as %s;", outputType) + } p(" }") p(" }") p(" }") diff --git a/internal/tsclientgen/golden_test.go b/internal/tsclientgen/golden_test.go index 95431d8e..d16f21fb 100644 --- a/internal/tsclientgen/golden_test.go +++ b/internal/tsclientgen/golden_test.go @@ -215,58 +215,72 @@ func TestTSClientGenESGoldenFiles(t *testing.T) { updateGolden := os.Getenv("UPDATE_GOLDEN") == "1" - // Smallest fixture exercising request-body encode + response decode. - protoFile := "multi_word_oneof.proto" - if _, statErr := os.Stat(filepath.Join(protoDir, protoFile)); os.IsNotExist(statErr) { - t.Fatalf("Proto file not found: %s", protoFile) + testCases := []struct { + name string + protoFile string + }{ + // Smallest fixture exercising request-body encode + response decode. + {name: "unary multi-word oneof", protoFile: "multi_word_oneof.proto"}, + // Server-streaming (SSE) fixture: async generators decode each event + // through fromJson. Mirrors the hand-rolled SSE_streaming case. + {name: "SSE streaming", protoFile: "sse.proto"}, } - outDir := t.TempDir() protoPaths := []string{"--proto_path=" + protoDir, "--proto_path=" + filepath.Join(projectRoot, "proto")} - // Pass 1: protoc-gen-es emits _pb.ts (imported by the client). - esArgs := []string{ - "--plugin=protoc-gen-es=" + esPluginPath, - "--es_out=" + outDir, - "--es_opt=target=ts,import_extension=js", - } - esArgs = append(esArgs, protoPaths...) - esArgs = append(esArgs, protoFile) - esCmd := exec.Command("protoc", esArgs...) - esCmd.Dir = protoDir - var esStderr bytes.Buffer - esCmd.Stderr = &esStderr - if runErr := esCmd.Run(); runErr != nil { - t.Fatalf("protoc (protoc-gen-es) failed: %v\nstderr: %s", runErr, esStderr.String()) - } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if _, statErr := os.Stat(filepath.Join(protoDir, tc.protoFile)); os.IsNotExist(statErr) { + t.Fatalf("Proto file not found: %s", tc.protoFile) + } - // Pass 2: sebuf ts-client in protobuf-es mode emits the transport client. - clientArgs := []string{ - "--plugin=protoc-gen-ts-client=" + pluginPath, - "--ts-client_out=" + outDir, - "--ts-client_opt=paths=source_relative,ts_runtime=protobuf-es", - } - clientArgs = append(clientArgs, protoPaths...) - clientArgs = append(clientArgs, protoFile) - clientCmd := exec.Command("protoc", clientArgs...) - clientCmd.Dir = protoDir - var clientStderr bytes.Buffer - clientCmd.Stderr = &clientStderr - if runErr := clientCmd.Run(); runErr != nil { - t.Fatalf("protoc (ts-client) failed: %v\nstderr: %s", runErr, clientStderr.String()) - } + outDir := t.TempDir() - for _, rel := range generatedTSFiles(t, outDir) { - generatedContent, readErr := os.ReadFile(filepath.Join(outDir, rel)) - if readErr != nil { - t.Fatalf("Failed to read generated file %s: %v", rel, readErr) - } - goldenPath := filepath.Join(goldenDir, rel) - if updateGolden { - updateGoldenFile(t, goldenPath, generatedContent) - continue - } - compareGoldenFile(t, rel, goldenPath, generatedContent) + // Pass 1: protoc-gen-es emits _pb.ts (imported by the client). + esArgs := []string{ + "--plugin=protoc-gen-es=" + esPluginPath, + "--es_out=" + outDir, + "--es_opt=target=ts,import_extension=js", + } + esArgs = append(esArgs, protoPaths...) + esArgs = append(esArgs, tc.protoFile) + esCmd := exec.Command("protoc", esArgs...) + esCmd.Dir = protoDir + var esStderr bytes.Buffer + esCmd.Stderr = &esStderr + if runErr := esCmd.Run(); runErr != nil { + t.Fatalf("protoc (protoc-gen-es) failed: %v\nstderr: %s", runErr, esStderr.String()) + } + + // Pass 2: sebuf ts-client in protobuf-es mode emits the transport client. + clientArgs := []string{ + "--plugin=protoc-gen-ts-client=" + pluginPath, + "--ts-client_out=" + outDir, + "--ts-client_opt=paths=source_relative,ts_runtime=protobuf-es", + } + clientArgs = append(clientArgs, protoPaths...) + clientArgs = append(clientArgs, tc.protoFile) + clientCmd := exec.Command("protoc", clientArgs...) + clientCmd.Dir = protoDir + var clientStderr bytes.Buffer + clientCmd.Stderr = &clientStderr + if runErr := clientCmd.Run(); runErr != nil { + t.Fatalf("protoc (ts-client) failed: %v\nstderr: %s", runErr, clientStderr.String()) + } + + for _, rel := range generatedTSFiles(t, outDir) { + generatedContent, readErr := os.ReadFile(filepath.Join(outDir, rel)) + if readErr != nil { + t.Fatalf("Failed to read generated file %s: %v", rel, readErr) + } + goldenPath := filepath.Join(goldenDir, rel) + if updateGolden { + updateGoldenFile(t, goldenPath, generatedContent) + continue + } + compareGoldenFile(t, rel, goldenPath, generatedContent) + } + }) } } diff --git a/internal/tsclientgen/testdata/golden/es/sse_client.ts b/internal/tsclientgen/testdata/golden/es/sse_client.ts new file mode 100644 index 00000000..36f29fe8 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/sse_client.ts @@ -0,0 +1,201 @@ +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: sse.proto + +import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { ApiError, ValidationError } from "./errors.js"; +import { EventSchema, GetStatusRequestSchema, ResourceEventSchema, StatusResponseSchema, StreamEventsRequestSchema, StreamFilteredEventsRequestSchema, StreamResourceEventsRequestSchema } from "./sse_pb.js"; +import type { Event, ResourceEvent, StatusResponse } from "./sse_pb.js"; + +export interface SSEServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface SSEServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class SSEServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: SSEServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async getStatus(_req: MessageInitShape, options?: SSEServiceCallOptions): Promise { + let path = "/api/v1/status"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return fromJson(StatusResponseSchema, await resp.json(), { ignoreUnknownFields: true }); + } + + async *streamEvents(_req: MessageInitShape, options?: SSEServiceCallOptions): AsyncGenerator { + let path = "/api/v1/events"; + const url = this.baseURL + path; + + const headers: Record = { + "Accept": "text/event-stream", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + const reader = resp.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + yield fromJson(EventSchema, JSON.parse(data), { ignoreUnknownFields: true }); + } + } + } + } finally { + reader.releaseLock(); + } + } + + async *streamResourceEvents(req: MessageInitShape, options?: SSEServiceCallOptions): AsyncGenerator { + let path = "/api/v1/resources/{resource_id}/events"; + path = path.replace("{resource_id}", encodeURIComponent(String(req.resourceId))); + const url = this.baseURL + path; + + const headers: Record = { + "Accept": "text/event-stream", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + const reader = resp.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + yield fromJson(ResourceEventSchema, JSON.parse(data), { ignoreUnknownFields: true }); + } + } + } + } finally { + reader.releaseLock(); + } + } + + async *streamFilteredEvents(req: MessageInitShape, options?: SSEServiceCallOptions): AsyncGenerator { + let path = "/api/v1/events/filtered"; + const params = new URLSearchParams(); + if (req.eventType != null && req.eventType !== "") params.set("type", String(req.eventType)); + if (req.limit != null && req.limit !== 0) params.set("limit", String(req.limit)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Accept": "text/event-stream", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + const reader = resp.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + yield fromJson(EventSchema, JSON.parse(data), { ignoreUnknownFields: true }); + } + } + } + } finally { + reader.releaseLock(); + } + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/internal/tsclientgen/testdata/golden/es/sse_pb.ts b/internal/tsclientgen/testdata/golden/es/sse_pb.ts new file mode 100644 index 00000000..231d5091 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/sse_pb.ts @@ -0,0 +1,208 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file sse.proto (package test.sse, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file sse.proto. + */ +export const file_sse: GenFile = /*@__PURE__*/ + fileDesc("Cglzc2UucHJvdG8SCHRlc3Quc3NlIhIKEEdldFN0YXR1c1JlcXVlc3QiOAoOU3RhdHVzUmVzcG9uc2USDgoGc3RhdHVzGAEgASgJEhYKDnVwdGltZV9zZWNvbmRzGAIgASgDIhUKE1N0cmVhbUV2ZW50c1JlcXVlc3QiRQoFRXZlbnQSCgoCaWQYASABKAkSDAoEdHlwZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgJEhEKCXRpbWVzdGFtcBgEIAEoAyIyChtTdHJlYW1SZXNvdXJjZUV2ZW50c1JlcXVlc3QSEwoLcmVzb3VyY2VfaWQYASABKAkiRgoNUmVzb3VyY2VFdmVudBITCgtyZXNvdXJjZV9pZBgBIAEoCRISCgpldmVudF90eXBlGAIgASgJEgwKBGRhdGEYAyABKAkiWQobU3RyZWFtRmlsdGVyZWRFdmVudHNSZXF1ZXN0Eh4KCmV2ZW50X3R5cGUYASABKAlCCsK1GAYKBHR5cGUSGgoFbGltaXQYAiABKAVCC8K1GAcKBWxpbWl0MrIDCgpTU0VTZXJ2aWNlElIKCUdldFN0YXR1cxIaLnRlc3Quc3NlLkdldFN0YXR1c1JlcXVlc3QaGC50ZXN0LnNzZS5TdGF0dXNSZXNwb25zZSIPmrUYCwoHL3N0YXR1cxABElEKDFN0cmVhbUV2ZW50cxIdLnRlc3Quc3NlLlN0cmVhbUV2ZW50c1JlcXVlc3QaDy50ZXN0LnNzZS5FdmVudCIRmrUYDQoHL2V2ZW50cxABGAESgQEKFFN0cmVhbVJlc291cmNlRXZlbnRzEiUudGVzdC5zc2UuU3RyZWFtUmVzb3VyY2VFdmVudHNSZXF1ZXN0GhcudGVzdC5zc2UuUmVzb3VyY2VFdmVudCIpmrUYJQofL3Jlc291cmNlcy97cmVzb3VyY2VfaWR9L2V2ZW50cxABGAESagoUU3RyZWFtRmlsdGVyZWRFdmVudHMSJS50ZXN0LnNzZS5TdHJlYW1GaWx0ZXJlZEV2ZW50c1JlcXVlc3QaDy50ZXN0LnNzZS5FdmVudCIamrUYFgoQL2V2ZW50cy9maWx0ZXJlZBABGAEaDaK1GAkKBy9hcGkvdjFCT1pNZ2l0aHViLmNvbS9TZWJhc3RpZW5NZWxraS9zZWJ1Zi9pbnRlcm5hbC9odHRwZ2VuL3Rlc3RkYXRhL2dlbmVyYXRlZDtnZW5lcmF0ZWRiBnByb3RvMw", [file_sebuf_http_annotations]); + +/** + * @generated from message test.sse.GetStatusRequest + */ +export type GetStatusRequest = Message<"test.sse.GetStatusRequest"> & { +}; + +/** + * Describes the message test.sse.GetStatusRequest. + * Use `create(GetStatusRequestSchema)` to create a new message. + */ +export const GetStatusRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 0); + +/** + * @generated from message test.sse.StatusResponse + */ +export type StatusResponse = Message<"test.sse.StatusResponse"> & { + /** + * @generated from field: string status = 1; + */ + status: string; + + /** + * @generated from field: int64 uptime_seconds = 2; + */ + uptimeSeconds: bigint; +}; + +/** + * Describes the message test.sse.StatusResponse. + * Use `create(StatusResponseSchema)` to create a new message. + */ +export const StatusResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 1); + +/** + * @generated from message test.sse.StreamEventsRequest + */ +export type StreamEventsRequest = Message<"test.sse.StreamEventsRequest"> & { +}; + +/** + * Describes the message test.sse.StreamEventsRequest. + * Use `create(StreamEventsRequestSchema)` to create a new message. + */ +export const StreamEventsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 2); + +/** + * @generated from message test.sse.Event + */ +export type Event = Message<"test.sse.Event"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string type = 2; + */ + type: string; + + /** + * @generated from field: string payload = 3; + */ + payload: string; + + /** + * @generated from field: int64 timestamp = 4; + */ + timestamp: bigint; +}; + +/** + * Describes the message test.sse.Event. + * Use `create(EventSchema)` to create a new message. + */ +export const EventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 3); + +/** + * @generated from message test.sse.StreamResourceEventsRequest + */ +export type StreamResourceEventsRequest = Message<"test.sse.StreamResourceEventsRequest"> & { + /** + * @generated from field: string resource_id = 1; + */ + resourceId: string; +}; + +/** + * Describes the message test.sse.StreamResourceEventsRequest. + * Use `create(StreamResourceEventsRequestSchema)` to create a new message. + */ +export const StreamResourceEventsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 4); + +/** + * @generated from message test.sse.ResourceEvent + */ +export type ResourceEvent = Message<"test.sse.ResourceEvent"> & { + /** + * @generated from field: string resource_id = 1; + */ + resourceId: string; + + /** + * @generated from field: string event_type = 2; + */ + eventType: string; + + /** + * @generated from field: string data = 3; + */ + data: string; +}; + +/** + * Describes the message test.sse.ResourceEvent. + * Use `create(ResourceEventSchema)` to create a new message. + */ +export const ResourceEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 5); + +/** + * @generated from message test.sse.StreamFilteredEventsRequest + */ +export type StreamFilteredEventsRequest = Message<"test.sse.StreamFilteredEventsRequest"> & { + /** + * @generated from field: string event_type = 1; + */ + eventType: string; + + /** + * @generated from field: int32 limit = 2; + */ + limit: number; +}; + +/** + * Describes the message test.sse.StreamFilteredEventsRequest. + * Use `create(StreamFilteredEventsRequestSchema)` to create a new message. + */ +export const StreamFilteredEventsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 6); + +/** + * @generated from service test.sse.SSEService + */ +export const SSEService: GenService<{ + /** + * Standard unary RPC (should be unaffected) + * + * @generated from rpc test.sse.SSEService.GetStatus + */ + getStatus: { + methodKind: "unary"; + input: typeof GetStatusRequestSchema; + output: typeof StatusResponseSchema; + }, + /** + * SSE streaming RPC + * + * @generated from rpc test.sse.SSEService.StreamEvents + */ + streamEvents: { + methodKind: "unary"; + input: typeof StreamEventsRequestSchema; + output: typeof EventSchema; + }, + /** + * SSE with path params + * + * @generated from rpc test.sse.SSEService.StreamResourceEvents + */ + streamResourceEvents: { + methodKind: "unary"; + input: typeof StreamResourceEventsRequestSchema; + output: typeof ResourceEventSchema; + }, + /** + * SSE with query params + * + * @generated from rpc test.sse.SSEService.StreamFilteredEvents + */ + streamFilteredEvents: { + methodKind: "unary"; + input: typeof StreamFilteredEventsRequestSchema; + output: typeof EventSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_sse, 0); + diff --git a/internal/tscommon/imports.go b/internal/tscommon/imports.go index 4603ab8b..bed6fb26 100644 --- a/internal/tscommon/imports.go +++ b/internal/tscommon/imports.go @@ -265,7 +265,7 @@ func (c *EmitContext) RefMessagePb(msg *protogen.Message) string { if msg == nil { return "" } - return c.Imports.NeedType(c.pbModuleSpec(msg.Desc.ParentFile()), QualifiedTSName(msg.Desc)) + return c.Imports.NeedType(c.pbModuleSpec(msg.Desc.ParentFile()), ESQualifiedName(msg.Desc)) } // RefMessageSchema returns the local name for a message's protobuf-es `*Schema` @@ -275,7 +275,7 @@ func (c *EmitContext) RefMessageSchema(msg *protogen.Message) string { if msg == nil { return "" } - return c.Imports.NeedValue(c.pbModuleSpec(msg.Desc.ParentFile()), QualifiedTSName(msg.Desc)+"Schema") + return c.Imports.NeedValue(c.pbModuleSpec(msg.Desc.ParentFile()), ESQualifiedName(msg.Desc)+"Schema") } // NeedProtobufES records that the file references the protobuf-es runtime diff --git a/internal/tscommon/types.go b/internal/tscommon/types.go index 433e3840..4fb86191 100644 --- a/internal/tscommon/types.go +++ b/internal/tscommon/types.go @@ -409,6 +409,27 @@ func QualifiedTSName(desc protoreflect.Descriptor) string { return prefix + string(desc.Name()) } +// ESQualifiedName returns the protoc-gen-es v2 local name for a message or enum +// descriptor: nested types are joined to their enclosing message names with an +// underscore (e.g. Outer.Inner -> "Outer_Inner"), matching protoc-gen-es output +// (verified against @bufbuild/protoc-gen-es v2.12.1). Top-level types return +// their leaf name unchanged. This differs from QualifiedTSName, which joins +// without a separator for sebuf's own hand-rolled type modules; the protobuf-es +// runtime mode imports from protoc-gen-es's _pb modules and must match +// its naming exactly. +func ESQualifiedName(desc protoreflect.Descriptor) string { + var parts []string + for parent := desc.Parent(); parent != nil; parent = parent.Parent() { + msg, ok := parent.(protoreflect.MessageDescriptor) + if !ok { + break + } + parts = append([]string{string(msg.Name())}, parts...) + } + parts = append(parts, string(desc.Name())) + return strings.Join(parts, "_") +} + // GenerateEnumType writes a TypeScript string union type for a protobuf enum. // Uses custom enum_value annotations if present, otherwise uses proto names. func GenerateEnumType(p Printer, enum *protogen.Enum) { From cc770921413b85b4f0e5ecff6330a4036dae888b Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:17:48 +0300 Subject: [PATCH 06/15] test(ts): wire-conformance for protobuf-es client (defaults filled, unknown fields ignored) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + internal/tsclientgen/conformance_test.go | 69 ++++++++++ .../tsclientgen/testdata/es/conformance.proto | 43 ++++++ .../testdata/es/conformance.test.mjs | 127 ++++++++++++++++++ .../tsclientgen/testdata/es/conformance_pb.js | 48 +++++++ .../es/conformance_response.canonical.json | 3 + 6 files changed, 294 insertions(+) create mode 100644 internal/tsclientgen/conformance_test.go create mode 100644 internal/tsclientgen/testdata/es/conformance.proto create mode 100644 internal/tsclientgen/testdata/es/conformance.test.mjs create mode 100644 internal/tsclientgen/testdata/es/conformance_pb.js create mode 100644 internal/tsclientgen/testdata/es/conformance_response.canonical.json diff --git a/.gitignore b/.gitignore index 9c24530e..cc5d91c1 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ internal/modules/*/internal/infrastructure/persistence/sqlc/ # Scratch / spike work (throwaway, never committed) .scratch/ + +# node_modules symlink created by the protobuf-es wire-conformance test +# (internal/tsclientgen/conformance_test.go) so node can resolve @bufbuild/protobuf +internal/tsclientgen/testdata/es/node_modules diff --git a/internal/tsclientgen/conformance_test.go b/internal/tsclientgen/conformance_test.go new file mode 100644 index 00000000..a59f9697 --- /dev/null +++ b/internal/tsclientgen/conformance_test.go @@ -0,0 +1,69 @@ +package tsclientgen + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestESWireConformance runs the protobuf-es wire-conformance proof +// (testdata/es/conformance.test.mjs) under node. It is the executable guarantee +// that an es-mode client decodes the Go server's default protojson correctly: +// +// - zero-valued scalars/bools/int64 and empty lists that the server OMITS are +// MATERIALIZED after fromJson ("" / 0 / false / 0n / []); +// - toJson re-emits the same canonical (zero-values-omitted) form; +// - unknown server fields are tolerated when ignoreUnknownFields is set. +// +// The .mjs imports @bufbuild/protobuf via bare specifiers (through the generated +// conformance_pb.js), so node needs a node_modules with @bufbuild/protobuf in +// testdata/es's directory tree. This test symlinks one in (git-ignored) from the +// Task-1 spike install, runs node, and cleans up. It SKIPS cleanly — never fails +// — when node or @bufbuild/protobuf is unavailable, so CI stays green. Mirrors +// the protoc/protoc-gen-es LookPath skips in golden_test.go. +// +// To run manually, see the header of testdata/es/conformance.test.mjs. +func TestESWireConformance(t *testing.T) { + nodeBin, err := exec.LookPath("node") + if err != nil { + t.Skip("node not found, skipping protobuf-es wire-conformance test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + esDir := filepath.Join(baseDir, "testdata", "es") + + // Locate a node_modules that resolves @bufbuild/protobuf. Allow an override + // for environments that install it elsewhere; otherwise reuse the git-ignored + // Task-1 spike install. + nodeModules := os.Getenv("SEBUF_ES_NODE_MODULES") + if nodeModules == "" { + nodeModules = filepath.Join(projectRoot, ".scratch", "es-spike", "node_modules") + } + if _, statErr := os.Stat(filepath.Join(nodeModules, "@bufbuild", "protobuf", "package.json")); statErr != nil { + t.Skipf("@bufbuild/protobuf not found under %s, skipping protobuf-es wire-conformance test", nodeModules) + } + + // node resolves bare specifiers from a node_modules in the file's directory + // tree, so symlink one into testdata/es. Only create/remove it if absent, so + // a developer's existing symlink is left untouched. + linkPath := filepath.Join(esDir, "node_modules") + if _, linkErr := os.Lstat(linkPath); os.IsNotExist(linkErr) { + if symErr := os.Symlink(nodeModules, linkPath); symErr != nil { + t.Fatalf("Failed to symlink node_modules for conformance test: %v", symErr) + } + t.Cleanup(func() { _ = os.Remove(linkPath) }) + } + + cmd := exec.Command(nodeBin, "conformance.test.mjs") + cmd.Dir = esDir + output, runErr := cmd.CombinedOutput() + t.Logf("conformance.test.mjs output:\n%s", output) + if runErr != nil { + t.Fatalf("protobuf-es wire-conformance check failed: %v", runErr) + } +} diff --git a/internal/tsclientgen/testdata/es/conformance.proto b/internal/tsclientgen/testdata/es/conformance.proto new file mode 100644 index 00000000..07dd376b --- /dev/null +++ b/internal/tsclientgen/testdata/es/conformance.proto @@ -0,0 +1,43 @@ +// Dedicated wire-conformance fixture for the protobuf-es TS client. +// +// This message deliberately mixes scalar, bool, int64 and repeated fields so +// the conformance check can OMIT every zero-valued field from the canonical +// JSON (mimicking the Go server's default protojson output) and then prove +// that protobuf-es MATERIALIZES those defaults after fromJson: +// - scalars -> "" / 0 +// - bool -> false +// - int64 -> 0n (bigint) +// - lists -> [] +// +// It is intentionally standalone: no service, no sebuf/http imports, so +// protoc-gen-es emits a clean _pb module that only depends on +// @bufbuild/protobuf. Generate the JS the conformance test imports with: +// +// protoc \ +// --plugin=protoc-gen-es=.scratch/es-spike/node_modules/.bin/protoc-gen-es \ +// --es_out=internal/tsclientgen/testdata/es \ +// --es_opt=target=js,import_extension=js \ +// --proto_path=internal/tsclientgen/testdata/es \ +// conformance.proto +syntax = "proto3"; + +package test.conformance; + +message Tag { + string name = 1; +} + +// ConformanceResponse mirrors a typical Go server response: every field below +// is left at its zero value in the canonical fixture and therefore OMITTED by +// protojson, except `id` which carries a real value to prove normal fields +// round-trip. +message ConformanceResponse { + string id = 1; + string name = 2; + int32 count = 3; + bool active = 4; + double ratio = 5; + int64 total = 6; + repeated string labels = 7; + repeated Tag tags = 8; +} diff --git a/internal/tsclientgen/testdata/es/conformance.test.mjs b/internal/tsclientgen/testdata/es/conformance.test.mjs new file mode 100644 index 00000000..eec3fa7d --- /dev/null +++ b/internal/tsclientgen/testdata/es/conformance.test.mjs @@ -0,0 +1,127 @@ +// Wire-conformance proof for the protobuf-es TS client. +// +// This is the guarantee behind ts_runtime=protobuf-es: a JSON body produced by +// the Go server (default protojson, which OMITS zero-valued scalars and empty +// lists) decodes through protobuf-es into a fully-materialized message, and is +// forward-compatible with fields the client does not yet know about. +// +// It asserts three things against ConformanceResponse (see conformance.proto): +// 1. fromJson(schema, canonical, { ignoreUnknownFields: true }) MATERIALIZES +// every omitted zero-value: scalars = "" / 0, bool = false, int64 = 0n, +// lists = []. The one present field (`id`) round-trips unchanged. +// 2. Re-serialising with toJson yields the SAME canonical form (zero-values +// omitted again) — superset-consistent with what the server sent. +// 3. fromJson with an EXTRA unknown field does NOT throw when +// ignoreUnknownFields is set (and DOES throw without it) — proving the +// client tolerates server fields added in the future. +// +// How to run +// ---------- +// This file does bare `import`s of @bufbuild/protobuf (via the generated +// conformance_pb.js), so node must be able to resolve @bufbuild/protobuf@2.12.1 +// from a node_modules in this file's directory tree. The simplest way is a +// symlink (git-ignored) pointing at the Task-1 spike install: +// +// ln -s ../../../../.scratch/es-spike/node_modules \ +// internal/tsclientgen/testdata/es/node_modules +// node internal/tsclientgen/testdata/es/conformance.test.mjs +// +// The Go wrapper (conformance_test.go) creates and removes that symlink +// automatically and SKIPS cleanly when node or @bufbuild/protobuf is absent. +// +// Exit code 0 = all assertions passed; non-zero = a failure was printed. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import assert from "node:assert/strict"; + +import { fromJson, toJson } from "@bufbuild/protobuf"; +import { ConformanceResponseSchema } from "./conformance_pb.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// The canonical body as the Go server would emit it: only the non-zero `id`. +// Every other field (scalars, bool, int64, both repeated fields) is OMITTED. +const canonical = JSON.parse( + readFileSync(join(here, "conformance_response.canonical.json"), "utf8"), +); + +let failures = 0; +function check(label, fn) { + try { + fn(); + console.log(` ok - ${label}`); + } catch (err) { + failures++; + console.error(` FAIL - ${label}`); + console.error(` ${err && err.message ? err.message : err}`); + } +} + +console.log("wire-conformance: protobuf-es ConformanceResponse"); + +// --- Assertion 1: defaults are materialized -------------------------------- +check("zero-values omitted by the server are materialized after fromJson", () => { + const msg = fromJson(ConformanceResponseSchema, canonical, { + ignoreUnknownFields: true, + }); + + // Present field round-trips unchanged. + assert.equal(msg.id, "note-123", "id should round-trip"); + + // Omitted scalars materialize to their zero value. + assert.equal(msg.name, "", 'string default should be ""'); + assert.equal(msg.count, 0, "int32 default should be 0"); + assert.equal(msg.active, false, "bool default should be false"); + assert.equal(msg.ratio, 0, "double default should be 0"); + + // int64 materializes as a bigint zero, not undefined. + assert.equal(typeof msg.total, "bigint", "int64 should be a bigint"); + assert.equal(msg.total, 0n, "int64 default should be 0n"); + + // Omitted repeated fields materialize as empty arrays, not undefined. + assert.ok(Array.isArray(msg.labels), "labels should be an array"); + assert.deepEqual(msg.labels, [], "repeated scalar default should be []"); + assert.ok(Array.isArray(msg.tags), "tags should be an array"); + assert.deepEqual(msg.tags, [], "repeated message default should be []"); +}); + +// --- Assertion 2: re-serialization is canonical (zero-values omitted) ------ +check("toJson re-emits the canonical form (zero-values omitted)", () => { + const msg = fromJson(ConformanceResponseSchema, canonical, { + ignoreUnknownFields: true, + }); + const reencoded = toJson(ConformanceResponseSchema, msg); + + // toJson must not leak the materialized defaults back onto the wire. + assert.deepEqual( + reencoded, + canonical, + "re-encoded JSON should equal the canonical body the server sent", + ); +}); + +// --- Assertion 3: unknown fields are tolerated ----------------------------- +check("unknown server field is ignored with ignoreUnknownFields", () => { + const withUnknown = { ...canonical, future_field: { nested: [1, 2, 3] }, extra: "x" }; + + // Must NOT throw with the flag the es-mode client always sets. + const msg = fromJson(ConformanceResponseSchema, withUnknown, { + ignoreUnknownFields: true, + }); + assert.equal(msg.id, "note-123", "known fields still decode alongside unknown ones"); + + // And it MUST throw without the flag — proving the flag is what makes the + // client forward-compatible (not merely lax input). + assert.throws( + () => fromJson(ConformanceResponseSchema, withUnknown), + "fromJson without ignoreUnknownFields should reject unknown fields", + ); +}); + +if (failures > 0) { + console.error(`\nwire-conformance: ${failures} assertion(s) FAILED`); + process.exit(1); +} +console.log("\nwire-conformance: all assertions passed"); diff --git a/internal/tsclientgen/testdata/es/conformance_pb.js b/internal/tsclientgen/testdata/es/conformance_pb.js new file mode 100644 index 00000000..5b51c129 --- /dev/null +++ b/internal/tsclientgen/testdata/es/conformance_pb.js @@ -0,0 +1,48 @@ +// Dedicated wire-conformance fixture for the protobuf-es TS client. +// +// This message deliberately mixes scalar, bool, int64 and repeated fields so +// the conformance check can OMIT every zero-valued field from the canonical +// JSON (mimicking the Go server's default protojson output) and then prove +// that protobuf-es MATERIALIZES those defaults after fromJson: +// - scalars -> "" / 0 +// - bool -> false +// - int64 -> 0n (bigint) +// - lists -> [] +// +// It is intentionally standalone: no service, no sebuf/http imports, so +// protoc-gen-es emits a clean _pb module that only depends on +// @bufbuild/protobuf. Generate the JS the conformance test imports with: +// +// protoc \ +// --plugin=protoc-gen-es=.scratch/es-spike/node_modules/.bin/protoc-gen-es \ +// --es_out=internal/tsclientgen/testdata/es \ +// --es_opt=target=js,import_extension=js \ +// --proto_path=internal/tsclientgen/testdata/es \ +// conformance.proto + +// @generated by protoc-gen-es v2.12.1 with parameter "target=js,import_extension=js" +// @generated from file conformance.proto (package test.conformance, syntax proto3) +/* eslint-disable */ + +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file conformance.proto. + */ +export const file_conformance = /*@__PURE__*/ + fileDesc("ChFjb25mb3JtYW5jZS5wcm90bxIQdGVzdC5jb25mb3JtYW5jZSITCgNUYWcSDAoEbmFtZRgBIAEoCSKhAQoTQ29uZm9ybWFuY2VSZXNwb25zZRIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWNvdW50GAMgASgFEg4KBmFjdGl2ZRgEIAEoCBINCgVyYXRpbxgFIAEoARINCgV0b3RhbBgGIAEoAxIOCgZsYWJlbHMYByADKAkSIwoEdGFncxgIIAMoCzIVLnRlc3QuY29uZm9ybWFuY2UuVGFnYgZwcm90bzM"); + +/** + * Describes the message test.conformance.Tag. + * Use `create(TagSchema)` to create a new message. + */ +export const TagSchema = /*@__PURE__*/ + messageDesc(file_conformance, 0); + +/** + * Describes the message test.conformance.ConformanceResponse. + * Use `create(ConformanceResponseSchema)` to create a new message. + */ +export const ConformanceResponseSchema = /*@__PURE__*/ + messageDesc(file_conformance, 1); + diff --git a/internal/tsclientgen/testdata/es/conformance_response.canonical.json b/internal/tsclientgen/testdata/es/conformance_response.canonical.json new file mode 100644 index 00000000..4daff0db --- /dev/null +++ b/internal/tsclientgen/testdata/es/conformance_response.canonical.json @@ -0,0 +1,3 @@ +{ + "id": "note-123" +} From d6ba9fdf46f1ad18505c7e20b7369181d2b94f02 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:50:00 +0300 Subject: [PATCH 07/15] feat(ts): generate protobuf-es transport server (fromJson/toJson responses) Co-Authored-By: Claude Opus 4.8 --- internal/tsservergen/generator.go | 99 +++++-- internal/tsservergen/golden_test.go | 113 +++++++ .../tsservergen/testdata/golden/es/errors.ts | 29 ++ .../testdata/golden/es/multi_word_oneof_pb.ts | 118 ++++++++ .../golden/es/multi_word_oneof_server.ts | 80 +++++ .../tsservergen/testdata/golden/es/sse_pb.ts | 208 +++++++++++++ .../testdata/golden/es/sse_server.ts | 277 ++++++++++++++++++ 7 files changed, 906 insertions(+), 18 deletions(-) create mode 100644 internal/tsservergen/testdata/golden/es/errors.ts create mode 100644 internal/tsservergen/testdata/golden/es/multi_word_oneof_pb.ts create mode 100644 internal/tsservergen/testdata/golden/es/multi_word_oneof_server.ts create mode 100644 internal/tsservergen/testdata/golden/es/sse_pb.ts create mode 100644 internal/tsservergen/testdata/golden/es/sse_server.ts diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index 079c134e..41d338ee 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -187,15 +187,28 @@ func (g *Generator) isSSEMethod(method *protogen.Method) bool { return config != nil && config.Stream } -// generateHandlerInterface generates the XxxServiceHandler interface. +// generateHandlerInterface generates the XxxServiceHandler interface. In +// protobuf-es mode the handler receives the decoded request as its branded +// protoc-gen-es message type (imported from _pb.js) and returns a +// MessageInitShape of the response schema, so implementations may return either +// a plain init object or an already-branded message; the generated route wraps +// the return value in create(...) before encoding. func (g *Generator) generateHandlerInterface(p tscommon.Printer, service *protogen.Service) { serviceName := service.GoName + es := g.ctx.MessageRuntime == tscommon.MessageRuntimeES p("export interface %sHandler {", serviceName) for _, method := range service.Methods { methodName := annotations.LowerFirst(method.GoName) - inputType := g.ctx.RefMessage(method.Input) - outputType := g.resolveOutputType(method) + var inputType, outputType string + if es { + inputType = g.ctx.RefMessagePb(method.Input) + outputType = "MessageInitShape" + g.ctx.NeedProtobufES() + } else { + inputType = g.ctx.RefMessage(method.Input) + outputType = g.resolveOutputType(method) + } if g.isSSEMethod(method) { p(" %s(ctx: ServerContext, req: %s): ReadableStream<%s>;", methodName, inputType, outputType) } else { @@ -376,7 +389,7 @@ func (g *Generator) generateRouteEntry(p tscommon.Printer, service *protogen.Ser } tsMethodName := annotations.LowerFirst(cfg.methodName) - outputType := g.resolveOutputType(method) + es := g.ctx.MessageRuntime == tscommon.MessageRuntimeES p(" {") p(` method: "%s",`, cfg.httpMethod) @@ -415,8 +428,17 @@ func (g *Generator) generateRouteEntry(p tscommon.Printer, service *protogen.Ser // Call handler p(" const result = await handler.%s(ctx, body);", tsMethodName) - // Return JSON response - p(" return new Response(JSON.stringify(result as %s), {", outputType) + // Return JSON response. In protobuf-es mode the result is normalized through + // create(...) and serialized with toJson(...) so the wire shape matches the + // canonical protojson encoding (Go-server parity); otherwise it is raw-cast. + if es { + resSchema := g.ctx.RefMessageSchema(method.Output) + g.ctx.NeedProtobufES() + p(" return new Response(JSON.stringify(toJson(%s, create(%s, result))), {", resSchema, resSchema) + } else { + outputType := g.resolveOutputType(method) + p(" return new Response(JSON.stringify(result as %s), {", outputType) + } p(" status: 200,") p(` headers: { "Content-Type": "application/json" },`) p(" });") @@ -454,6 +476,7 @@ func (g *Generator) generateSSERouteEntry( cfg *rpcRouteConfig, ) error { tsMethodName := annotations.LowerFirst(cfg.methodName) + es := g.ctx.MessageRuntime == tscommon.MessageRuntimeES p(" {") p(` method: "%s",`, cfg.httpMethod) @@ -500,7 +523,16 @@ func (g *Generator) generateSSERouteEntry( p(" while (true) {") p(" const { done, value } = await reader.read();") p(" if (done) break;") - p(" controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\\n\\n`));") + // In protobuf-es mode each yielded event is normalized through create(...) + // and serialized with toJson(...) for canonical protojson (Go-server parity); + // otherwise the value is stringified directly (no raw cast needed here). + valueExpr := "value" + if es { + resSchema := g.ctx.RefMessageSchema(method.Output) + g.ctx.NeedProtobufES() + valueExpr = fmt.Sprintf("toJson(%s, create(%s, value))", resSchema, resSchema) + } + p(" controller.enqueue(encoder.encode(`data: ${JSON.stringify(%s)}\\n\\n`));", valueExpr) p(" }") p(" controller.close();") p(" } catch (err) {") @@ -642,10 +674,19 @@ func (g *Generator) generatePathParamExtraction(p tscommon.Printer, cfg *rpcRout p("") } -// generateBodyParsing generates code to parse JSON request body. +// generateBodyParsing generates code to parse JSON request body. In protobuf-es +// mode the JSON body is decoded through fromJson with ignoreUnknownFields +// (forward-compat, mandatory), yielding a branded message; otherwise it is +// raw-cast to the request type. func (g *Generator) generateBodyParsing(p tscommon.Printer, method *protogen.Method, tsMethodName string) { - inputType := g.ctx.RefMessage(method.Input) - p(" const body = await req.json() as %s;", inputType) + if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { + reqSchema := g.ctx.RefMessageSchema(method.Input) + g.ctx.NeedProtobufES() + p(" const body = fromJson(%s, await req.json(), { ignoreUnknownFields: true });", reqSchema) + } else { + inputType := g.ctx.RefMessage(method.Input) + p(" const body = await req.json() as %s;", inputType) + } // Optional validation hook p(" if (options?.validateRequest) {") @@ -657,23 +698,45 @@ func (g *Generator) generateBodyParsing(p tscommon.Printer, method *protogen.Met p("") } -// generateQueryParamParsing generates code to parse query parameters. +// generateQueryParamParsing generates code to parse query parameters. In +// protobuf-es mode the request object built from path/query params is wrapped in +// create(Schema, {...}) so the handler receives a real branded message +// (never a raw cast); otherwise it is a type-annotated object literal. func (g *Generator) generateQueryParamParsing( p tscommon.Printer, cfg *rpcRouteConfig, method *protogen.Method, tsMethodName string, ) { - inputType := g.ctx.RefMessage(method.Input) + es := g.ctx.MessageRuntime == tscommon.MessageRuntimeES + var inputType, reqSchema string + if es { + reqSchema = g.ctx.RefMessageSchema(method.Input) + g.ctx.NeedProtobufES() + } else { + inputType = g.ctx.RefMessage(method.Input) + } + + // bodyOpen/bodyClose bracket the request object literal. In es mode the + // literal is passed to create(...); otherwise it is a typed literal. + bodyOpen := fmt.Sprintf(" const body: %s = {", inputType) + bodyClose := " };" + if es { + bodyOpen = fmt.Sprintf(" const body = create(%s, {", reqSchema) + bodyClose = " });" + } if len(cfg.queryParams) == 0 { - if len(cfg.pathParamFields) > 0 { - p(" const body: %s = {", inputType) + switch { + case len(cfg.pathParamFields) > 0: + p(bodyOpen) for _, ppf := range cfg.pathParamFields { g.emitPathParamAssignment(p, ppf, " ", ",") } - p(" };") - } else { + p(bodyClose) + case es: + p(" const body = create(%s, {});", reqSchema) + default: p(" const body = {} as %s;", inputType) } p("") @@ -687,7 +750,7 @@ func (g *Generator) generateQueryParamParsing( p(" const url = new URL(req.url, \"http://localhost\");") p(" const params = url.searchParams;") } - p(" const body: %s = {", inputType) + p(bodyOpen) // Include path param fields in the literal so TS sees all required properties for _, ppf := range cfg.pathParamFields { g.emitPathParamAssignment(p, ppf, " ", ",") @@ -695,7 +758,7 @@ func (g *Generator) generateQueryParamParsing( for _, qp := range cfg.queryParams { g.generateQueryParamField(p, qp) } - p(" };") + p(bodyClose) // Optional validation hook p(" if (options?.validateRequest) {") diff --git a/internal/tsservergen/golden_test.go b/internal/tsservergen/golden_test.go index be81b68d..3e3dc6ff 100644 --- a/internal/tsservergen/golden_test.go +++ b/internal/tsservergen/golden_test.go @@ -172,6 +172,119 @@ func TestTSServerGenGoldenFiles(t *testing.T) { } } +// TestTSServerGenESGoldenFiles tests protobuf-es transport server generation +// (ts_runtime=protobuf-es) against golden files. It runs protoc twice into one +// output dir: once with protoc-gen-es (emitting _pb.ts message schemas) +// and once with the sebuf ts-server plugin in es mode (emitting the server that +// decodes requests with fromJson and encodes responses through +// toJson(create(...))). Every emitted .ts file is compared against +// testdata/golden/es/. +// +// To update golden files after intentional changes (protoc-gen-es must be on PATH): +// +// UPDATE_GOLDEN=1 go test -run TestTSServerGenESGoldenFiles +func TestTSServerGenESGoldenFiles(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping golden file tests") + } + esPluginPath, esErr := exec.LookPath("protoc-gen-es") + if esErr != nil { + t.Skip("protoc-gen-es not found, skipping protobuf-es golden file tests") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + goldenDir := filepath.Join(baseDir, "testdata", "golden", "es") + + if mkdirErr := os.MkdirAll(goldenDir, 0o755); mkdirErr != nil { + t.Fatalf("Failed to create golden directory: %v", mkdirErr) + } + + pluginPath := filepath.Join(projectRoot, "bin", "protoc-gen-ts-server") + if _, buildStatErr := os.Stat(pluginPath); os.IsNotExist(buildStatErr) { + buildCmd := exec.Command("make", "build") + buildCmd.Dir = projectRoot + if buildErr := buildCmd.Run(); buildErr != nil { + t.Fatalf("Failed to build plugin: %v", buildErr) + } + } + + updateGolden := os.Getenv("UPDATE_GOLDEN") == "1" + + testCases := []struct { + name string + protoFile string + }{ + // Smallest fixture exercising request-body decode + response encode. + {name: "unary multi-word oneof", protoFile: "multi_word_oneof.proto"}, + // Server-streaming (SSE) fixture: each yielded event is encoded through + // toJson(create(...)). Mirrors the hand-rolled SSE_streaming case. + {name: "SSE streaming", protoFile: "sse.proto"}, + } + + protoPaths := []string{"--proto_path=" + protoDir, "--proto_path=" + filepath.Join(projectRoot, "proto")} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if _, statErr := os.Stat(filepath.Join(protoDir, tc.protoFile)); os.IsNotExist(statErr) { + t.Fatalf("Proto file not found: %s", tc.protoFile) + } + + outDir := t.TempDir() + + // Pass 1: protoc-gen-es emits _pb.ts (imported by the server). + esArgs := []string{ + "--plugin=protoc-gen-es=" + esPluginPath, + "--es_out=" + outDir, + "--es_opt=target=ts,import_extension=js", + } + esArgs = append(esArgs, protoPaths...) + esArgs = append(esArgs, tc.protoFile) + esCmd := exec.Command("protoc", esArgs...) + esCmd.Dir = protoDir + var esStderr bytes.Buffer + esCmd.Stderr = &esStderr + if runErr := esCmd.Run(); runErr != nil { + t.Fatalf("protoc (protoc-gen-es) failed: %v\nstderr: %s", runErr, esStderr.String()) + } + + // Pass 2: sebuf ts-server in protobuf-es mode emits the transport server. + serverArgs := []string{ + "--plugin=protoc-gen-ts-server=" + pluginPath, + "--ts-server_out=" + outDir, + "--ts-server_opt=paths=source_relative,ts_runtime=protobuf-es", + } + serverArgs = append(serverArgs, protoPaths...) + serverArgs = append(serverArgs, tc.protoFile) + serverCmd := exec.Command("protoc", serverArgs...) + serverCmd.Dir = protoDir + var serverStderr bytes.Buffer + serverCmd.Stderr = &serverStderr + if runErr := serverCmd.Run(); runErr != nil { + t.Fatalf("protoc (ts-server) failed: %v\nstderr: %s", runErr, serverStderr.String()) + } + + for _, rel := range generatedTSFiles(t, outDir) { + generatedContent, readErr := os.ReadFile(filepath.Join(outDir, rel)) + if readErr != nil { + t.Fatalf("Failed to read generated file %s: %v", rel, readErr) + } + goldenPath := filepath.Join(goldenDir, rel) + if updateGolden { + updateGoldenFile(t, goldenPath, generatedContent) + continue + } + compareGoldenFile(t, rel, goldenPath, generatedContent) + } + }) + } +} + // generatedTSFiles returns the relative paths of every .ts file under dir, sorted. func generatedTSFiles(t *testing.T, dir string) []string { t.Helper() diff --git a/internal/tsservergen/testdata/golden/es/errors.ts b/internal/tsservergen/testdata/golden/es/errors.ts new file mode 100644 index 00000000..991abd2c --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/errors.ts @@ -0,0 +1,29 @@ +// Code generated by sebuf. DO NOT EDIT. + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + diff --git a/internal/tsservergen/testdata/golden/es/multi_word_oneof_pb.ts b/internal/tsservergen/testdata/golden/es/multi_word_oneof_pb.ts new file mode 100644 index 00000000..4c794a67 --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/multi_word_oneof_pb.ts @@ -0,0 +1,118 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file multi_word_oneof.proto (package testdata.multi_word_oneof, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file multi_word_oneof.proto. + */ +export const file_multi_word_oneof: GenFile = /*@__PURE__*/ + fileDesc("ChZtdWx0aV93b3JkX29uZW9mLnByb3RvEhl0ZXN0ZGF0YS5tdWx0aV93b3JkX29uZW9mIhsKC1RleHRDb250ZW50EgwKBGJvZHkYASABKAkiOgoMSW1hZ2VDb250ZW50EgsKA3VybBgBIAEoCRINCgV3aWR0aBgCIAEoBRIOCgZoZWlnaHQYAyABKAUiqwEKDk11bHRpV29yZEV2ZW50EgoKAmlkGAEgASgJEjoKCGJpZ190ZXh0GAIgASgLMiYudGVzdGRhdGEubXVsdGlfd29yZF9vbmVvZi5UZXh0Q29udGVudEgAEjwKCWJpZ19pbWFnZRgDIAEoCzInLnRlc3RkYXRhLm11bHRpX3dvcmRfb25lb2YuSW1hZ2VDb250ZW50SABCEwoRc3VwZXJfdGl0bGVfaW1hZ2UyrwEKFU11bHRpV29yZE9uZW9mU2VydmljZRKGAQoSVGVzdE11bHRpV29yZEV2ZW50EikudGVzdGRhdGEubXVsdGlfd29yZF9vbmVvZi5NdWx0aVdvcmRFdmVudBopLnRlc3RkYXRhLm11bHRpX3dvcmRfb25lb2YuTXVsdGlXb3JkRXZlbnQiGpq1GBYKEi9ldmVudHMvbXVsdGktd29yZBACGg2itRgJCgcvYXBpL3YxQl1aW2dpdGh1Yi5jb20vU2ViYXN0aWVuTWVsa2kvc2VidWYvaW50ZXJuYWwvdHNjbGllbnRnZW4vdGVzdGRhdGEvbXVsdGl3b3Jkb25lb2Y7bXVsdGl3b3Jkb25lb2ZiBnByb3RvMw", [file_sebuf_http_annotations]); + +/** + * Variant message types + * + * @generated from message testdata.multi_word_oneof.TextContent + */ +export type TextContent = Message<"testdata.multi_word_oneof.TextContent"> & { + /** + * @generated from field: string body = 1; + */ + body: string; +}; + +/** + * Describes the message testdata.multi_word_oneof.TextContent. + * Use `create(TextContentSchema)` to create a new message. + */ +export const TextContentSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_multi_word_oneof, 0); + +/** + * @generated from message testdata.multi_word_oneof.ImageContent + */ +export type ImageContent = Message<"testdata.multi_word_oneof.ImageContent"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: int32 width = 2; + */ + width: number; + + /** + * @generated from field: int32 height = 3; + */ + height: number; +}; + +/** + * Describes the message testdata.multi_word_oneof.ImageContent. + * Use `create(ImageContentSchema)` to create a new message. + */ +export const ImageContentSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_multi_word_oneof, 1); + +/** + * Message with a multi-word oneof name and no oneof_config annotation. + * + * The oneof name (super_title_image) is deliberately multi-word: it must + * surface in the generated TypeScript only as the PascalCase union type name + * (MultiWordEventSuperTitleImage) and must NEVER leak as a raw snake_case + * wrapper property. Because the oneof is un-annotated it takes the + * presence-discriminated flat-union path, matching plain protojson. + * + * @generated from message testdata.multi_word_oneof.MultiWordEvent + */ +export type MultiWordEvent = Message<"testdata.multi_word_oneof.MultiWordEvent"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from oneof testdata.multi_word_oneof.MultiWordEvent.super_title_image + */ + superTitleImage: { + /** + * @generated from field: testdata.multi_word_oneof.TextContent big_text = 2; + */ + value: TextContent; + case: "bigText"; + } | { + /** + * @generated from field: testdata.multi_word_oneof.ImageContent big_image = 3; + */ + value: ImageContent; + case: "bigImage"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message testdata.multi_word_oneof.MultiWordEvent. + * Use `create(MultiWordEventSchema)` to create a new message. + */ +export const MultiWordEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_multi_word_oneof, 2); + +/** + * @generated from service testdata.multi_word_oneof.MultiWordOneofService + */ +export const MultiWordOneofService: GenService<{ + /** + * @generated from rpc testdata.multi_word_oneof.MultiWordOneofService.TestMultiWordEvent + */ + testMultiWordEvent: { + methodKind: "unary"; + input: typeof MultiWordEventSchema; + output: typeof MultiWordEventSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_multi_word_oneof, 0); + diff --git a/internal/tsservergen/testdata/golden/es/multi_word_oneof_server.ts b/internal/tsservergen/testdata/golden/es/multi_word_oneof_server.ts new file mode 100644 index 00000000..ed095d4e --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/multi_word_oneof_server.ts @@ -0,0 +1,80 @@ +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: multi_word_oneof.proto + +import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { FieldViolation, ValidationError } from "./errors.js"; +import { MultiWordEventSchema } from "./multi_word_oneof_pb.js"; +import type { MultiWordEvent } from "./multi_word_oneof_pb.js"; + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface MultiWordOneofServiceHandler { + testMultiWordEvent(ctx: ServerContext, req: MultiWordEvent): Promise>; +} + +export function createMultiWordOneofServiceRoutes( + handler: MultiWordOneofServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "POST", + path: "/api/v1/events/multi-word", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = fromJson(MultiWordEventSchema, await req.json(), { ignoreUnknownFields: true }); + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("testMultiWordEvent", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.testMultiWordEvent(ctx, body); + return new Response(JSON.stringify(toJson(MultiWordEventSchema, create(MultiWordEventSchema, result))), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + diff --git a/internal/tsservergen/testdata/golden/es/sse_pb.ts b/internal/tsservergen/testdata/golden/es/sse_pb.ts new file mode 100644 index 00000000..231d5091 --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/sse_pb.ts @@ -0,0 +1,208 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file sse.proto (package test.sse, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file sse.proto. + */ +export const file_sse: GenFile = /*@__PURE__*/ + fileDesc("Cglzc2UucHJvdG8SCHRlc3Quc3NlIhIKEEdldFN0YXR1c1JlcXVlc3QiOAoOU3RhdHVzUmVzcG9uc2USDgoGc3RhdHVzGAEgASgJEhYKDnVwdGltZV9zZWNvbmRzGAIgASgDIhUKE1N0cmVhbUV2ZW50c1JlcXVlc3QiRQoFRXZlbnQSCgoCaWQYASABKAkSDAoEdHlwZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgJEhEKCXRpbWVzdGFtcBgEIAEoAyIyChtTdHJlYW1SZXNvdXJjZUV2ZW50c1JlcXVlc3QSEwoLcmVzb3VyY2VfaWQYASABKAkiRgoNUmVzb3VyY2VFdmVudBITCgtyZXNvdXJjZV9pZBgBIAEoCRISCgpldmVudF90eXBlGAIgASgJEgwKBGRhdGEYAyABKAkiWQobU3RyZWFtRmlsdGVyZWRFdmVudHNSZXF1ZXN0Eh4KCmV2ZW50X3R5cGUYASABKAlCCsK1GAYKBHR5cGUSGgoFbGltaXQYAiABKAVCC8K1GAcKBWxpbWl0MrIDCgpTU0VTZXJ2aWNlElIKCUdldFN0YXR1cxIaLnRlc3Quc3NlLkdldFN0YXR1c1JlcXVlc3QaGC50ZXN0LnNzZS5TdGF0dXNSZXNwb25zZSIPmrUYCwoHL3N0YXR1cxABElEKDFN0cmVhbUV2ZW50cxIdLnRlc3Quc3NlLlN0cmVhbUV2ZW50c1JlcXVlc3QaDy50ZXN0LnNzZS5FdmVudCIRmrUYDQoHL2V2ZW50cxABGAESgQEKFFN0cmVhbVJlc291cmNlRXZlbnRzEiUudGVzdC5zc2UuU3RyZWFtUmVzb3VyY2VFdmVudHNSZXF1ZXN0GhcudGVzdC5zc2UuUmVzb3VyY2VFdmVudCIpmrUYJQofL3Jlc291cmNlcy97cmVzb3VyY2VfaWR9L2V2ZW50cxABGAESagoUU3RyZWFtRmlsdGVyZWRFdmVudHMSJS50ZXN0LnNzZS5TdHJlYW1GaWx0ZXJlZEV2ZW50c1JlcXVlc3QaDy50ZXN0LnNzZS5FdmVudCIamrUYFgoQL2V2ZW50cy9maWx0ZXJlZBABGAEaDaK1GAkKBy9hcGkvdjFCT1pNZ2l0aHViLmNvbS9TZWJhc3RpZW5NZWxraS9zZWJ1Zi9pbnRlcm5hbC9odHRwZ2VuL3Rlc3RkYXRhL2dlbmVyYXRlZDtnZW5lcmF0ZWRiBnByb3RvMw", [file_sebuf_http_annotations]); + +/** + * @generated from message test.sse.GetStatusRequest + */ +export type GetStatusRequest = Message<"test.sse.GetStatusRequest"> & { +}; + +/** + * Describes the message test.sse.GetStatusRequest. + * Use `create(GetStatusRequestSchema)` to create a new message. + */ +export const GetStatusRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 0); + +/** + * @generated from message test.sse.StatusResponse + */ +export type StatusResponse = Message<"test.sse.StatusResponse"> & { + /** + * @generated from field: string status = 1; + */ + status: string; + + /** + * @generated from field: int64 uptime_seconds = 2; + */ + uptimeSeconds: bigint; +}; + +/** + * Describes the message test.sse.StatusResponse. + * Use `create(StatusResponseSchema)` to create a new message. + */ +export const StatusResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 1); + +/** + * @generated from message test.sse.StreamEventsRequest + */ +export type StreamEventsRequest = Message<"test.sse.StreamEventsRequest"> & { +}; + +/** + * Describes the message test.sse.StreamEventsRequest. + * Use `create(StreamEventsRequestSchema)` to create a new message. + */ +export const StreamEventsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 2); + +/** + * @generated from message test.sse.Event + */ +export type Event = Message<"test.sse.Event"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string type = 2; + */ + type: string; + + /** + * @generated from field: string payload = 3; + */ + payload: string; + + /** + * @generated from field: int64 timestamp = 4; + */ + timestamp: bigint; +}; + +/** + * Describes the message test.sse.Event. + * Use `create(EventSchema)` to create a new message. + */ +export const EventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 3); + +/** + * @generated from message test.sse.StreamResourceEventsRequest + */ +export type StreamResourceEventsRequest = Message<"test.sse.StreamResourceEventsRequest"> & { + /** + * @generated from field: string resource_id = 1; + */ + resourceId: string; +}; + +/** + * Describes the message test.sse.StreamResourceEventsRequest. + * Use `create(StreamResourceEventsRequestSchema)` to create a new message. + */ +export const StreamResourceEventsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 4); + +/** + * @generated from message test.sse.ResourceEvent + */ +export type ResourceEvent = Message<"test.sse.ResourceEvent"> & { + /** + * @generated from field: string resource_id = 1; + */ + resourceId: string; + + /** + * @generated from field: string event_type = 2; + */ + eventType: string; + + /** + * @generated from field: string data = 3; + */ + data: string; +}; + +/** + * Describes the message test.sse.ResourceEvent. + * Use `create(ResourceEventSchema)` to create a new message. + */ +export const ResourceEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 5); + +/** + * @generated from message test.sse.StreamFilteredEventsRequest + */ +export type StreamFilteredEventsRequest = Message<"test.sse.StreamFilteredEventsRequest"> & { + /** + * @generated from field: string event_type = 1; + */ + eventType: string; + + /** + * @generated from field: int32 limit = 2; + */ + limit: number; +}; + +/** + * Describes the message test.sse.StreamFilteredEventsRequest. + * Use `create(StreamFilteredEventsRequestSchema)` to create a new message. + */ +export const StreamFilteredEventsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sse, 6); + +/** + * @generated from service test.sse.SSEService + */ +export const SSEService: GenService<{ + /** + * Standard unary RPC (should be unaffected) + * + * @generated from rpc test.sse.SSEService.GetStatus + */ + getStatus: { + methodKind: "unary"; + input: typeof GetStatusRequestSchema; + output: typeof StatusResponseSchema; + }, + /** + * SSE streaming RPC + * + * @generated from rpc test.sse.SSEService.StreamEvents + */ + streamEvents: { + methodKind: "unary"; + input: typeof StreamEventsRequestSchema; + output: typeof EventSchema; + }, + /** + * SSE with path params + * + * @generated from rpc test.sse.SSEService.StreamResourceEvents + */ + streamResourceEvents: { + methodKind: "unary"; + input: typeof StreamResourceEventsRequestSchema; + output: typeof ResourceEventSchema; + }, + /** + * SSE with query params + * + * @generated from rpc test.sse.SSEService.StreamFilteredEvents + */ + streamFilteredEvents: { + methodKind: "unary"; + input: typeof StreamFilteredEventsRequestSchema; + output: typeof EventSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_sse, 0); + diff --git a/internal/tsservergen/testdata/golden/es/sse_server.ts b/internal/tsservergen/testdata/golden/es/sse_server.ts new file mode 100644 index 00000000..3a637864 --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/sse_server.ts @@ -0,0 +1,277 @@ +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: sse.proto + +import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { FieldViolation, ValidationError } from "./errors.js"; +import { EventSchema, GetStatusRequestSchema, ResourceEventSchema, StatusResponseSchema, StreamEventsRequestSchema, StreamFilteredEventsRequestSchema, StreamResourceEventsRequestSchema } from "./sse_pb.js"; +import type { GetStatusRequest, StreamEventsRequest, StreamFilteredEventsRequest, StreamResourceEventsRequest } from "./sse_pb.js"; + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface SSEServiceHandler { + getStatus(ctx: ServerContext, req: GetStatusRequest): Promise>; + streamEvents(ctx: ServerContext, req: StreamEventsRequest): ReadableStream>; + streamResourceEvents(ctx: ServerContext, req: StreamResourceEventsRequest): ReadableStream>; + streamFilteredEvents(ctx: ServerContext, req: StreamFilteredEventsRequest): ReadableStream>; +} + +export function createSSEServiceRoutes( + handler: SSEServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "GET", + path: "/api/v1/status", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = create(GetStatusRequestSchema, {}); + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getStatus(ctx, body); + return new Response(JSON.stringify(toJson(StatusResponseSchema, create(StatusResponseSchema, result))), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + { + method: "GET", + path: "/api/v1/events", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = create(StreamEventsRequestSchema, {}); + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const stream = handler.streamEvents(ctx, body); + + const sseStream = new ReadableStream({ + async start(controller) { + const reader = stream.getReader(); + const encoder = new TextEncoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(toJson(EventSchema, create(EventSchema, value)))}\n\n`)); + } + controller.close(); + } catch (err) { + controller.enqueue( + encoder.encode(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`), + ); + controller.close(); + } + }, + }); + + return new Response(sseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + { + method: "GET", + path: "/api/v1/resources/{resource_id}/events", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const pathSegments = url.pathname.split("/"); + pathParams["resource_id"] = decodeURIComponent(pathSegments[4] ?? ""); + + const body = create(StreamResourceEventsRequestSchema, { + resourceId: pathParams["resource_id"], + }); + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const stream = handler.streamResourceEvents(ctx, body); + + const sseStream = new ReadableStream({ + async start(controller) { + const reader = stream.getReader(); + const encoder = new TextEncoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(toJson(ResourceEventSchema, create(ResourceEventSchema, value)))}\n\n`)); + } + controller.close(); + } catch (err) { + controller.enqueue( + encoder.encode(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`), + ); + controller.close(); + } + }, + }); + + return new Response(sseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + { + method: "GET", + path: "/api/v1/events/filtered", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body = create(StreamFilteredEventsRequestSchema, { + eventType: params.get("type") ?? "", + limit: Number(params.get("limit") ?? "0"), + }); + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("streamFilteredEvents", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const stream = handler.streamFilteredEvents(ctx, body); + + const sseStream = new ReadableStream({ + async start(controller) { + const reader = stream.getReader(); + const encoder = new TextEncoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(toJson(EventSchema, create(EventSchema, value)))}\n\n`)); + } + controller.close(); + } catch (err) { + controller.enqueue( + encoder.encode(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`), + ); + controller.close(); + } + }, + }); + + return new Response(sseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + From c68bf7cfae5950bc1ba71295a6cc952f9c37c847 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:15:21 +0300 Subject: [PATCH 08/15] fix(ts): import only referenced protobuf-es symbols (noUnusedLocals-safe) Co-Authored-By: Claude Opus 4.8 --- internal/tsclientgen/generator.go | 2 - internal/tsclientgen/modules.go | 2 + .../testdata/golden/es/sse_client.ts | 2 +- internal/tscommon/imports.go | 100 +++++++++++++----- internal/tsservergen/generator.go | 9 +- internal/tsservergen/modules.go | 2 + .../testdata/golden/es/sse_server.ts | 2 +- 7 files changed, 86 insertions(+), 33 deletions(-) diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index afe206d8..6ec471c8 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -230,7 +230,6 @@ func (g *Generator) generateRPCMethod(p printer, service *protogen.Service, meth resSchema = g.ctx.RefMessageSchema(method.Output) inputType = "MessageInitShape" outputType = g.ctx.RefMessagePb(method.Output) - g.ctx.NeedProtobufES() } else { inputType = g.ctx.RefMessage(method.Input) outputType = g.resolveOutputType(method) @@ -276,7 +275,6 @@ func (g *Generator) generateSSERPCMethod( resSchema = g.ctx.RefMessageSchema(method.Output) inputType = "MessageInitShape" outputType = g.ctx.RefMessagePb(method.Output) - g.ctx.NeedProtobufES() } else { inputType = g.ctx.RefMessage(method.Input) outputType = g.resolveOutputType(method) diff --git a/internal/tsclientgen/modules.go b/internal/tsclientgen/modules.go index 5fd98a62..d8f8862a 100644 --- a/internal/tsclientgen/modules.go +++ b/internal/tsclientgen/modules.go @@ -43,6 +43,8 @@ func (g *Generator) emitClientModule(file *protogen.File) string { } // Import only the error helpers actually referenced in the body. g.ctx.NeedErrors(tscommon.UsedErrorSymbols(body)...) + // Import only the protobuf-es runtime symbols actually referenced. + g.ctx.NeedProtobufES(tscommon.UsedProtobufESSymbols(body)...) dp := tscommon.DirectPrinter(gf) dp("// Code generated by protoc-gen-ts-client. DO NOT EDIT.") diff --git a/internal/tsclientgen/testdata/golden/es/sse_client.ts b/internal/tsclientgen/testdata/golden/es/sse_client.ts index 36f29fe8..66d78b46 100644 --- a/internal/tsclientgen/testdata/golden/es/sse_client.ts +++ b/internal/tsclientgen/testdata/golden/es/sse_client.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: sse.proto -import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { fromJson, type MessageInitShape } from "@bufbuild/protobuf"; import { ApiError, ValidationError } from "./errors.js"; import { EventSchema, GetStatusRequestSchema, ResourceEventSchema, StatusResponseSchema, StreamEventsRequestSchema, StreamFilteredEventsRequestSchema, StreamResourceEventsRequestSchema } from "./sse_pb.js"; import type { Event, ResourceEvent, StatusResponse } from "./sse_pb.js"; diff --git a/internal/tscommon/imports.go b/internal/tscommon/imports.go index bed6fb26..737b9154 100644 --- a/internal/tscommon/imports.go +++ b/internal/tscommon/imports.go @@ -3,6 +3,7 @@ package tscommon import ( "fmt" "path" + "regexp" "sort" "strings" @@ -10,6 +11,11 @@ import ( "google.golang.org/protobuf/reflect/protoreflect" ) +// protobufESSymbolOrder is the canonical emission order of the protobuf-es +// runtime symbols in the `@bufbuild/protobuf` import. MessageInitShape is a +// type-only import; the rest are value imports. +var protobufESSymbolOrder = []string{"create", "fromJson", "toJson", "MessageInitShape"} + // errorsModule is the extensionless module path of the shared error-helpers // file emitted at the output root in modules mode. const errorsModule = "errors" @@ -70,19 +76,21 @@ type ImportTracker struct { usedAlias map[string]string // local alias -> owning "spec\x00symbol" errorSyms map[string]bool // error helpers referenced (value import) errorsSpec string - // needProtobufES records that the file references the protobuf-es runtime - // helpers (create/fromJson/toJson/MessageInitShape). - needProtobufES bool + // protobufESSyms records which protobuf-es runtime helpers + // (create/fromJson/toJson/MessageInitShape) the file actually references, + // so Render imports only those. + protobufESSyms map[string]bool } // NewImportTracker returns an empty tracker. func NewImportTracker() *ImportTracker { return &ImportTracker{ - typeImports: map[string][]importedSymbol{}, - valueImports: map[string][]importedSymbol{}, - aliasOf: map[string]string{}, - usedAlias: map[string]string{}, - errorSyms: map[string]bool{}, + typeImports: map[string][]importedSymbol{}, + valueImports: map[string][]importedSymbol{}, + aliasOf: map[string]string{}, + usedAlias: map[string]string{}, + errorSyms: map[string]bool{}, + protobufESSyms: map[string]bool{}, } } @@ -133,10 +141,13 @@ func (t *ImportTracker) NeedValue(spec, symbol string) string { return alias } -// NeedProtobufESRuntime records that the file references the protobuf-es runtime -// helpers, so Render emits the fixed `@bufbuild/protobuf` import. -func (t *ImportTracker) NeedProtobufESRuntime() { - t.needProtobufES = true +// NeedProtobufES records that the file references the given protobuf-es runtime +// helpers, so Render emits only those symbols in the `@bufbuild/protobuf` +// import. +func (t *ImportTracker) NeedProtobufES(symbols ...string) { + for _, s := range symbols { + t.protobufESSyms[s] = true + } } // NeedErrors records that the given shared error helpers are referenced, @@ -150,20 +161,31 @@ func (t *ImportTracker) NeedErrors(spec string, symbols ...string) { // Empty reports whether no imports were recorded. func (t *ImportTracker) Empty() bool { - return !t.needProtobufES && len(t.errorSyms) == 0 && + return len(t.protobufESSyms) == 0 && len(t.errorSyms) == 0 && len(t.valueImports) == 0 && len(t.typeImports) == 0 } -// Render writes the import block: the fixed protobuf-es runtime import (es mode -// only), the value import for error helpers, sorted value imports (protobuf-es -// `*Schema` handles), then sorted type-only imports. It emits a trailing blank -// line when anything was written. +// Render writes the import block: the protobuf-es runtime import (es mode only, +// listing only the referenced symbols), the value import for error helpers, +// sorted value imports (protobuf-es `*Schema` handles), then sorted type-only +// imports. It emits a trailing blank line when anything was written. func (t *ImportTracker) Render(p Printer) { if t.Empty() { return } - if t.needProtobufES { - p(`import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf";`) + if len(t.protobufESSyms) > 0 { + parts := make([]string, 0, len(protobufESSymbolOrder)) + for _, s := range protobufESSymbolOrder { + if !t.protobufESSyms[s] { + continue + } + if s == "MessageInitShape" { + parts = append(parts, "type "+s) + } else { + parts = append(parts, s) + } + } + p(`import { %s } from "@bufbuild/protobuf";`, strings.Join(parts, ", ")) } if len(t.errorSyms) > 0 { syms := make([]string, 0, len(t.errorSyms)) @@ -278,12 +300,13 @@ func (c *EmitContext) RefMessageSchema(msg *protogen.Message) string { return c.Imports.NeedValue(c.pbModuleSpec(msg.Desc.ParentFile()), ESQualifiedName(msg.Desc)+"Schema") } -// NeedProtobufES records that the file references the protobuf-es runtime -// helpers (create/fromJson/toJson/MessageInitShape). -func (c *EmitContext) NeedProtobufES() { - if c.modules() { - c.Imports.NeedProtobufESRuntime() +// NeedProtobufES records that the file references the given protobuf-es runtime +// helpers (a subset of create/fromJson/toJson/MessageInitShape). +func (c *EmitContext) NeedProtobufES(symbols ...string) { + if !c.modules() || len(symbols) == 0 { + return } + c.Imports.NeedProtobufES(symbols...) } // NeedErrors records that this file references the given shared error helpers. @@ -294,6 +317,35 @@ func (c *EmitContext) NeedErrors(symbols ...string) { c.Imports.NeedErrors(RelativeImportSpecifier(c.SelfModule, errorsModule), symbols...) } +// protobufESSymbolPatterns holds the word-boundary matcher for each protobuf-es +// runtime symbol, so a match on `create` is not triggered by a longer +// identifier that merely contains it. +var protobufESSymbolPatterns = func() map[string]*regexp.Regexp { + m := make(map[string]*regexp.Regexp, len(protobufESSymbolOrder)) + for _, s := range protobufESSymbolOrder { + m[s] = regexp.MustCompile(`\b` + regexp.QuoteMeta(s) + `\b`) + } + return m +}() + +// UsedProtobufESSymbols returns the protobuf-es runtime symbols +// (create/fromJson/toJson/MessageInitShape) referenced anywhere in the given +// body lines, in canonical import order, using word-boundary matching so a +// symbol is not matched inside a longer identifier. +func UsedProtobufESSymbols(lines []string) []string { + var used []string + for _, sym := range protobufESSymbolOrder { + re := protobufESSymbolPatterns[sym] + for _, line := range lines { + if re.MatchString(line) { + used = append(used, sym) + break + } + } + } + return used +} + // UsedErrorSymbols returns the error-helper symbols referenced anywhere in the // given body lines, so a generated file imports only what it uses (these // symbols are distinct, none a substring of another). diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index 41d338ee..81139f51 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -204,7 +204,6 @@ func (g *Generator) generateHandlerInterface(p tscommon.Printer, service *protog if es { inputType = g.ctx.RefMessagePb(method.Input) outputType = "MessageInitShape" - g.ctx.NeedProtobufES() } else { inputType = g.ctx.RefMessage(method.Input) outputType = g.resolveOutputType(method) @@ -433,7 +432,6 @@ func (g *Generator) generateRouteEntry(p tscommon.Printer, service *protogen.Ser // canonical protojson encoding (Go-server parity); otherwise it is raw-cast. if es { resSchema := g.ctx.RefMessageSchema(method.Output) - g.ctx.NeedProtobufES() p(" return new Response(JSON.stringify(toJson(%s, create(%s, result))), {", resSchema, resSchema) } else { outputType := g.resolveOutputType(method) @@ -529,7 +527,6 @@ func (g *Generator) generateSSERouteEntry( valueExpr := "value" if es { resSchema := g.ctx.RefMessageSchema(method.Output) - g.ctx.NeedProtobufES() valueExpr = fmt.Sprintf("toJson(%s, create(%s, value))", resSchema, resSchema) } p(" controller.enqueue(encoder.encode(`data: ${JSON.stringify(%s)}\\n\\n`));", valueExpr) @@ -586,6 +583,8 @@ func (g *Generator) emitPathParamAssignment( ) { if ppf.field != nil && ppf.field.Desc.Kind() == protoreflect.EnumKind && ppf.field.Enum != nil { enumName := g.ctx.RefEnum(ppf.field.Enum) + // TODO(es): enum path-param merge needs conversion, not a cast + // (protobuf-es enums are numeric; pathParams values are strings). p( "%s%s: pathParams[\"%s\"] as %s%s", prefix, ppf.jsonName, ppf.protoName, enumName, suffix, @@ -604,6 +603,8 @@ func (g *Generator) generatePathParamMerge(p tscommon.Printer, cfg *rpcRouteConf for _, ppf := range cfg.pathParamFields { if ppf.field != nil && ppf.field.Desc.Kind() == protoreflect.EnumKind && ppf.field.Enum != nil { enumName := g.ctx.RefEnum(ppf.field.Enum) + // TODO(es): enum path-param merge needs conversion, not a cast + // (protobuf-es enums are numeric; pathParams values are strings). p( " body.%s = pathParams[\"%s\"] as %s;", ppf.jsonName, ppf.protoName, enumName, @@ -681,7 +682,6 @@ func (g *Generator) generatePathParamExtraction(p tscommon.Printer, cfg *rpcRout func (g *Generator) generateBodyParsing(p tscommon.Printer, method *protogen.Method, tsMethodName string) { if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { reqSchema := g.ctx.RefMessageSchema(method.Input) - g.ctx.NeedProtobufES() p(" const body = fromJson(%s, await req.json(), { ignoreUnknownFields: true });", reqSchema) } else { inputType := g.ctx.RefMessage(method.Input) @@ -712,7 +712,6 @@ func (g *Generator) generateQueryParamParsing( var inputType, reqSchema string if es { reqSchema = g.ctx.RefMessageSchema(method.Input) - g.ctx.NeedProtobufES() } else { inputType = g.ctx.RefMessage(method.Input) } diff --git a/internal/tsservergen/modules.go b/internal/tsservergen/modules.go index 68ca0d4f..1658fe85 100644 --- a/internal/tsservergen/modules.go +++ b/internal/tsservergen/modules.go @@ -54,6 +54,8 @@ func (g *Generator) emitServerModule(file *protogen.File) (string, error) { } // Import only the error helpers actually referenced in the body. g.ctx.NeedErrors(tscommon.UsedErrorSymbols(body)...) + // Import only the protobuf-es runtime symbols actually referenced. + g.ctx.NeedProtobufES(tscommon.UsedProtobufESSymbols(body)...) dp := tscommon.DirectPrinter(gf) dp("// Code generated by protoc-gen-ts-server. DO NOT EDIT.") diff --git a/internal/tsservergen/testdata/golden/es/sse_server.ts b/internal/tsservergen/testdata/golden/es/sse_server.ts index 3a637864..ca544451 100644 --- a/internal/tsservergen/testdata/golden/es/sse_server.ts +++ b/internal/tsservergen/testdata/golden/es/sse_server.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: sse.proto -import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { create, toJson, type MessageInitShape } from "@bufbuild/protobuf"; import { FieldViolation, ValidationError } from "./errors.js"; import { EventSchema, GetStatusRequestSchema, ResourceEventSchema, StatusResponseSchema, StreamEventsRequestSchema, StreamFilteredEventsRequestSchema, StreamResourceEventsRequestSchema } from "./sse_pb.js"; import type { GetStatusRequest, StreamEventsRequest, StreamFilteredEventsRequest, StreamResourceEventsRequest } from "./sse_pb.js"; From 7356695ba9c1a3d49080cb9980644456679107c7 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:21:40 +0300 Subject: [PATCH 09/15] docs(ts): document ts_runtime=protobuf-es mode and its consumer-facing differences --- docs/client-generation.md | 129 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/docs/client-generation.md b/docs/client-generation.md index b31d2597..5b5fe041 100644 --- a/docs/client-generation.md +++ b/docs/client-generation.md @@ -635,6 +635,135 @@ try { The proto definition serves as the single source of truth for error shapes — both server and client use the same generated interface for type safety across the wire. +## protobuf-es runtime (TypeScript) + +By default the TypeScript generators emit their own plain-interface types (the +per-proto type module `.ts` described above). Passing +`ts_runtime=protobuf-es` switches both the client and server generators into +**protobuf-es transport mode**: instead of declaring their own interfaces, they +consume the message types and schemas emitted by +[`protoc-gen-es`](https://github.com/bufbuild/protobuf-es) (the `_pb.ts` +files) and serialize on the wire through protobuf-es's canonical +`fromJson`/`toJson`. This gives you protobuf-es's fully spec-compliant proto3 +JSON encoding (defaults, `bigint`, oneofs, well-known types) for free, shared +across your whole app. + +### buf.gen.yaml shape + +In this mode you run `protoc-gen-es` **and** the sebuf ts-client (or ts-server) +plugin together, into the same output directory. protoc-gen-es emits the +`_pb.ts` message schemas; the sebuf plugin emits the transport +client/server that imports them. Both sebuf plugins still require +`strategy: all` (see the sections above): + +```yaml +version: v2 +plugins: + # 1. protoc-gen-es: emits _pb.ts message classes + schemas + - local: protoc-gen-es + out: ./generated + opt: + - target=ts + - import_extension=js + # 2. sebuf ts-client in protobuf-es transport mode + - local: protoc-gen-ts-client + out: ./generated + opt: + - paths=source_relative + - ts_runtime=protobuf-es + strategy: all + # (and/or) sebuf ts-server in the same mode + - local: protoc-gen-ts-server + out: ./generated + opt: + - paths=source_relative + - ts_runtime=protobuf-es + strategy: all +``` + +The exact plugin options mirror what the generator's golden tests invoke: +`--es_opt=target=ts,import_extension=js` for protoc-gen-es, and +`--ts-client_opt=paths=source_relative,ts_runtime=protobuf-es` / +`--ts-server_opt=paths=source_relative,ts_runtime=protobuf-es` for the sebuf +plugins. + +### Runtime dependency + +protobuf-es transport mode has a runtime dependency on **`@bufbuild/protobuf` +v2** (the `_pb.ts` files and the generated transport both import +`create`, `fromJson`, `toJson`, `MessageInitShape`, and the message/`*Schema` +symbols from it). Install it in the consuming project: + +```bash +npm install @bufbuild/protobuf +``` + +The generated goldens were produced with `@bufbuild/protoc-gen-es@2.12.1`. + +The generated transport import only pulls in the `@bufbuild/protobuf` symbols a +given file actually uses (e.g. a GET-only client imports just `fromJson` and +`MessageInitShape`, not `create`/`toJson`), so the output compiles cleanly under +strict `noUnusedLocals`. + +### Consumer-facing differences + +Compared to the default plain-interface mode, code that uses the generated +client/server sees protobuf-es's types and conventions: + +- **Branded messages.** Message types are protobuf-es branded types + (`Message<"pkg.Name"> & {...}`), not structural interfaces. You cannot pass an + arbitrary object literal where a full message is expected. +- **`create()` for construction; `MessageInitShape` on the boundaries.** To + build a message value use protobuf-es's `create(SchemaSymbol, {...})`. Client + methods and server handlers do not force you to call `create()` yourself, + though: client methods accept `MessageInitShape` (the + loose init shape), and server handler methods **return** + `MessageInitShape` — the generated code calls + `create()` for you before encoding. So handlers can `return { ... }` with a + plain init object. +- **Oneofs as `{ case, value }`.** A protobuf oneof is a discriminated union + (`{ case: "bigText"; value: TextContent } | { case: "bigImage"; value: + ImageContent } | { case: undefined; value?: undefined }`), not sibling + optional fields. +- **int64 as `bigint`.** 64-bit integer fields (`int64`, `uint64`, `sint64`, + `fixed64`, `sfixed64`) are represented as `bigint`, following protobuf-es. + +### Server-streaming (SSE) + +Server-streaming RPCs **are** supported in protobuf-es mode. On the client, a +streaming RPC is an `async function*` returning `AsyncGenerator` that yields +each event decoded with `fromJson(...)`; on the server, the handler returns a +`ReadableStream>` and the generated route +encodes each value with `toJson(create(EventSchema, value))` into an SSE +`data:` frame. Both directions go through protobuf-es's canonical JSON. + +### Server `EmitDefaultValues` is not required for TS correctness + +In protobuf-es transport mode the client decodes every response with +`fromJson(Schema, ..., { ignoreUnknownFields: true })`. protobuf-es's `fromJson` +fills in proto3 default values for any field the server omitted, so the +consumer always gets a fully-populated message even when the server does **not** +set the `EmitDefaultValues` flag. That flag is therefore not needed for +TypeScript correctness in this mode (unknown/extra fields on the wire are also +ignored rather than causing an error). + +### Known limitations + +- **Enum path parameters are not yet supported.** protobuf-es enums are numeric, + but path-parameter values arrive as strings; the current merge emits an + `as ` cast, which is unsound (see the + `// TODO(es): enum path-param merge needs conversion, not a cast` in + `internal/tsservergen/generator.go`). A POST/GET RPC that has an **enum** path + parameter is unsupported under `ts_runtime=protobuf-es` for now. **String** + path parameters work fine. +- **Prefer top-level messages for RPC input/output.** Nested-message local + names are reconciled to protoc-gen-es's underscore form (`Outer_Inner` / + `Outer_InnerSchema`, via `ESQualifiedName`), so nested messages used as RPC + I/O do resolve — but this was verified against + `@bufbuild/protoc-gen-es@2.12.1`; if you use a different protoc-gen-es version + and hit a naming mismatch, promoting the message to top level is the safe + workaround. + ## See Also - **[HTTP Generation Guide](./http-generation.md)** - Go server-side handler generation From dd270c648bcd5afda6ffcccef149cba42237e229 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:12:59 +0300 Subject: [PATCH 10/15] fix(ts): fail loud on enum path/query params in protobuf-es mode Enum path/query parameters (including repeated enum query params) are not representable in protobuf-es mode: enums are numeric there but URL params arrive as strings, and RefEnum resolves to the hand-rolled ./.js type module that es-mode deliberately does not emit -> downstream TS2307. Instead of emitting uncompilable output, both generators now reject enum path/query params at generation time via checkNoEnumParamsES with a clear error. Adds a non-enum unary GET golden (string path param + scalar query params, get_params.proto) wired into the es golden tests and typechecked under --strict --noUnusedLocals, plus in-process tests asserting the fail-loud path. Updates docs to cover enum path AND query params. Removes obsolete TODO(es). Co-Authored-By: Claude Opus 4.8 --- docs/client-generation.md | 21 +++-- internal/tsclientgen/generator.go | 40 ++++++++ internal/tsclientgen/golden_test.go | 2 + internal/tsclientgen/inprocess_golden_test.go | 27 ++++++ internal/tsclientgen/modules.go | 14 ++- .../testdata/golden/es/get_params_client.ts | 73 +++++++++++++++ .../testdata/golden/es/get_params_pb.ts | 93 +++++++++++++++++++ .../testdata/proto/get_params.proto | 38 ++++++++ internal/tscommon/runtime.go | 20 +++- internal/tsservergen/generator.go | 43 ++++++++- internal/tsservergen/golden_test.go | 2 + internal/tsservergen/inprocess_golden_test.go | 27 ++++++ .../testdata/golden/es/get_params_pb.ts | 93 +++++++++++++++++++ .../testdata/golden/es/get_params_server.ts | 90 ++++++++++++++++++ .../testdata/proto/get_params.proto | 38 ++++++++ 15 files changed, 604 insertions(+), 17 deletions(-) create mode 100644 internal/tsclientgen/testdata/golden/es/get_params_client.ts create mode 100644 internal/tsclientgen/testdata/golden/es/get_params_pb.ts create mode 100644 internal/tsclientgen/testdata/proto/get_params.proto create mode 100644 internal/tsservergen/testdata/golden/es/get_params_pb.ts create mode 100644 internal/tsservergen/testdata/golden/es/get_params_server.ts create mode 100644 internal/tsservergen/testdata/proto/get_params.proto diff --git a/docs/client-generation.md b/docs/client-generation.md index 5b5fe041..2a7119b6 100644 --- a/docs/client-generation.md +++ b/docs/client-generation.md @@ -749,13 +749,20 @@ ignored rather than causing an error). ### Known limitations -- **Enum path parameters are not yet supported.** protobuf-es enums are numeric, - but path-parameter values arrive as strings; the current merge emits an - `as ` cast, which is unsound (see the - `// TODO(es): enum path-param merge needs conversion, not a cast` in - `internal/tsservergen/generator.go`). A POST/GET RPC that has an **enum** path - parameter is unsupported under `ts_runtime=protobuf-es` for now. **String** - path parameters work fine. +- **Enum path AND query parameters are not yet supported.** protobuf-es enums + are numeric, but path- and query-parameter values arrive as strings on the + wire; safely bridging the two requires a name↔number conversion that is not + yet implemented. This applies to enum path parameters, enum query parameters, + and repeated enum query parameters. Rather than emit code that fails a + downstream `tsc` (with a confusing `TS2307: Cannot find module './.js'`, + because the hand-rolled enum type module is deliberately not emitted in + es-mode), **the generator now fails loud at generation time** with a clear + error, e.g. `ts_runtime=protobuf-es: enum query parameter "region" on + Service.Method is not yet supported`. Both the client and server generators + enforce this (see `checkNoEnumParamsES` in `internal/tsclientgen/generator.go` + and `internal/tsservergen/generator.go`). **String, integer, boolean, and + other scalar** path and query parameters work fine under + `ts_runtime=protobuf-es`. - **Prefer top-level messages for RPC input/output.** Nested-message local names are reconciled to protoc-gen-es's underscore form (`Outer_Inner` / `Outer_InnerSchema`, via `ESQualifiedName`), so nested messages used as RPC diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index 6ec471c8..1c13c5d6 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -5,6 +5,7 @@ import ( "net/http" "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/SebastienMelki/sebuf/internal/annotations" "github.com/SebastienMelki/sebuf/internal/tscommon" @@ -34,6 +35,17 @@ func (g *Generator) Generate() error { func (g *Generator) generateServiceClient(p printer, service *protogen.Service) error { serviceName := service.GoName + // Enum path/query parameters are not representable in protobuf-es mode + // (numeric enums vs string URL params); fail loud rather than emit code that + // fails downstream tsc. + if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { + for _, method := range service.Methods { + if err := checkNoEnumParamsES(service, method); err != nil { + return err + } + } + } + // Client options interface g.generateClientOptionsInterface(p, service) @@ -211,6 +223,34 @@ func (g *Generator) buildRPCMethodConfig(service *protogen.Service, method *prot } } +// checkNoEnumParamsES reports a generation-time error if the method has an +// enum-typed path or query parameter (including repeated enum query params), +// which protobuf-es mode cannot yet represent. See UnsupportedEnumParamError. +func checkNoEnumParamsES(service *protogen.Service, method *protogen.Method) error { + httpConfig := annotations.GetMethodHTTPConfig(method) + var pathParams []string + if httpConfig != nil { + pathParams = httpConfig.PathParams + } + if len(pathParams) > 0 { + fieldMap := make(map[string]*protogen.Field, len(method.Input.Fields)) + for _, f := range method.Input.Fields { + fieldMap[string(f.Desc.Name())] = f + } + for _, param := range pathParams { + if f, ok := fieldMap[param]; ok && f.Desc.Kind() == protoreflect.EnumKind { + return tscommon.UnsupportedEnumParamError("path", param, service.GoName, method.GoName) + } + } + } + for _, qp := range annotations.GetQueryParams(method.Input) { + if qp.Field != nil && qp.Field.Desc.Kind() == protoreflect.EnumKind { + return tscommon.UnsupportedEnumParamError("query", qp.FieldName, service.GoName, method.GoName) + } + } + return nil +} + // generateRPCMethod generates a single async RPC method. func (g *Generator) generateRPCMethod(p printer, service *protogen.Service, method *protogen.Method) { cfg := g.buildRPCMethodConfig(service, method) diff --git a/internal/tsclientgen/golden_test.go b/internal/tsclientgen/golden_test.go index d16f21fb..7af4f2c6 100644 --- a/internal/tsclientgen/golden_test.go +++ b/internal/tsclientgen/golden_test.go @@ -224,6 +224,8 @@ func TestTSClientGenESGoldenFiles(t *testing.T) { // Server-streaming (SSE) fixture: async generators decode each event // through fromJson. Mirrors the hand-rolled SSE_streaming case. {name: "SSE streaming", protoFile: "sse.proto"}, + // Unary GET with a string path param and scalar (non-enum) query params. + {name: "unary GET path+query params", protoFile: "get_params.proto"}, } protoPaths := []string{"--proto_path=" + protoDir, "--proto_path=" + filepath.Join(projectRoot, "proto")} diff --git a/internal/tsclientgen/inprocess_golden_test.go b/internal/tsclientgen/inprocess_golden_test.go index cd9cfbcd..1dbb9d40 100644 --- a/internal/tsclientgen/inprocess_golden_test.go +++ b/internal/tsclientgen/inprocess_golden_test.go @@ -47,6 +47,33 @@ func TestTSClientGenInProcess(t *testing.T) { } } +// TestTSClientGenESRejectsEnumParams asserts that in protobuf-es mode the +// generator fails loud (rather than emitting uncompilable output) when a method +// has an enum-typed path or query parameter. query_params.proto has both an +// enum query param (SearchAdvanced) and an enum path param (GetByRegion). +func TestTSClientGenESRejectsEnumParams(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping in-process es enum-param test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + + plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{"query_params.proto"}) + genErr := New(plugin, tscommon.MessageRuntimeES).Generate() + if genErr == nil { + t.Fatal("expected Generate() to fail for enum path/query param in es mode, but it succeeded") + } + if !strings.Contains(genErr.Error(), "ts_runtime=protobuf-es: enum") || + !strings.Contains(genErr.Error(), "is not yet supported") { + t.Errorf("expected enum-param unsupported error, got: %v", genErr) + } +} + // TestTSClientGenInProcessReservedName drives a fixture whose message is named // ValidationError (a reserved error-helper symbol) and asserts Generate() fails // with a rename error, covering tscommon.CheckReservedNames via EmitSharedModules. diff --git a/internal/tsclientgen/modules.go b/internal/tsclientgen/modules.go index d8f8862a..e0be5e26 100644 --- a/internal/tsclientgen/modules.go +++ b/internal/tsclientgen/modules.go @@ -19,7 +19,11 @@ func (g *Generator) generateModules() error { if !file.Generate || len(file.Services) == 0 { continue } - moduleFiles = append(moduleFiles, g.emitClientModule(file)) + name, emitErr := g.emitClientModule(file) + if emitErr != nil { + return emitErr + } + moduleFiles = append(moduleFiles, name) } tscommon.EmitPackageBarrels(g.plugin, moduleFiles) return nil @@ -29,7 +33,7 @@ func (g *Generator) generateModules() error { // request/response types from their canonical modules and the shared error // helpers. It returns the output-relative filename it emitted (ending in // ".ts"), so the caller can fold it into the per-package barrel. -func (g *Generator) emitClientModule(file *protogen.File) string { +func (g *Generator) emitClientModule(file *protogen.File) (string, error) { module := file.GeneratedFilenamePrefix + "_client" gf := g.plugin.NewGeneratedFile(module+".ts", "") tracker := tscommon.NewImportTracker() @@ -39,7 +43,9 @@ func (g *Generator) emitClientModule(file *protogen.File) string { var body []string bp := printer(tscommon.BufferedPrinter(&body)) for _, service := range file.Services { - _ = g.generateServiceClient(bp, service) + if err := g.generateServiceClient(bp, service); err != nil { + return "", err + } } // Import only the error helpers actually referenced in the body. g.ctx.NeedErrors(tscommon.UsedErrorSymbols(body)...) @@ -54,5 +60,5 @@ func (g *Generator) emitClientModule(file *protogen.File) string { for _, line := range body { gf.P(line) } - return module + ".ts" + return module + ".ts", nil } diff --git a/internal/tsclientgen/testdata/golden/es/get_params_client.ts b/internal/tsclientgen/testdata/golden/es/get_params_client.ts new file mode 100644 index 00000000..29f13fbb --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/get_params_client.ts @@ -0,0 +1,73 @@ +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: get_params.proto + +import { fromJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { ApiError, ValidationError } from "./errors.js"; +import { GetItemRequestSchema, ItemSchema } from "./get_params_pb.js"; +import type { Item } from "./get_params_pb.js"; + +export interface GetParamsServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface GetParamsServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class GetParamsServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: GetParamsServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async getItem(req: MessageInitShape, options?: GetParamsServiceCallOptions): Promise { + let path = "/api/v1/items/{item_id}"; + path = path.replace("{item_id}", encodeURIComponent(String(req.itemId))); + const params = new URLSearchParams(); + if (req.query != null && req.query !== "") params.set("q", String(req.query)); + if (req.limit != null && req.limit !== 0) params.set("limit", String(req.limit)); + if (req.includeArchived) params.set("include_archived", String(req.includeArchived)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return fromJson(ItemSchema, await resp.json(), { ignoreUnknownFields: true }); + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/internal/tsclientgen/testdata/golden/es/get_params_pb.ts b/internal/tsclientgen/testdata/golden/es/get_params_pb.ts new file mode 100644 index 00000000..f4ff0e35 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/get_params_pb.ts @@ -0,0 +1,93 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file get_params.proto (package test.getparams, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file get_params.proto. + */ +export const file_get_params: GenFile = /*@__PURE__*/ + fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCIgCgRJdGVtEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkyfAoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAEaDaK1GAkKBy9hcGkvdjFCT1pNZ2l0aHViLmNvbS9TZWJhc3RpZW5NZWxraS9zZWJ1Zi9pbnRlcm5hbC9odHRwZ2VuL3Rlc3RkYXRhL2dlbmVyYXRlZDtnZW5lcmF0ZWRiBnByb3RvMw", [file_sebuf_http_annotations]); + +/** + * @generated from message test.getparams.GetItemRequest + */ +export type GetItemRequest = Message<"test.getparams.GetItemRequest"> & { + /** + * String path parameter. + * + * @generated from field: string item_id = 1; + */ + itemId: string; + + /** + * Scalar query parameters (no enum). + * + * @generated from field: string query = 2; + */ + query: string; + + /** + * @generated from field: int32 limit = 3; + */ + limit: number; + + /** + * @generated from field: bool include_archived = 4; + */ + includeArchived: boolean; +}; + +/** + * Describes the message test.getparams.GetItemRequest. + * Use `create(GetItemRequestSchema)` to create a new message. + */ +export const GetItemRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 0); + +/** + * @generated from message test.getparams.Item + */ +export type Item = Message<"test.getparams.Item"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message test.getparams.Item. + * Use `create(ItemSchema)` to create a new message. + */ +export const ItemSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 1); + +/** + * GetParamsService exercises a unary GET RPC with a string path parameter and + * scalar (non-enum) query parameters under protobuf-es mode. This locks down + * the path/query surface that the POST-unary and GET-streaming es fixtures do + * not cover. + * + * @generated from service test.getparams.GetParamsService + */ +export const GetParamsService: GenService<{ + /** + * @generated from rpc test.getparams.GetParamsService.GetItem + */ + getItem: { + methodKind: "unary"; + input: typeof GetItemRequestSchema; + output: typeof ItemSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_get_params, 0); + diff --git a/internal/tsclientgen/testdata/proto/get_params.proto b/internal/tsclientgen/testdata/proto/get_params.proto new file mode 100644 index 00000000..e73ebe2b --- /dev/null +++ b/internal/tsclientgen/testdata/proto/get_params.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package test.getparams; + +option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/generated;generated"; + +import "sebuf/http/annotations.proto"; + +// GetParamsService exercises a unary GET RPC with a string path parameter and +// scalar (non-enum) query parameters under protobuf-es mode. This locks down +// the path/query surface that the POST-unary and GET-streaming es fixtures do +// not cover. +service GetParamsService { + option (sebuf.http.service_config) = { + base_path: "/api/v1" + }; + + rpc GetItem(GetItemRequest) returns (Item) { + option (sebuf.http.config) = { + path: "/items/{item_id}" + method: HTTP_METHOD_GET + }; + } +} + +message GetItemRequest { + // String path parameter. + string item_id = 1; + // Scalar query parameters (no enum). + string query = 2 [(sebuf.http.query) = { name: "q" }]; + int32 limit = 3 [(sebuf.http.query) = { name: "limit" }]; + bool include_archived = 4 [(sebuf.http.query) = { name: "include_archived" }]; +} + +message Item { + string id = 1; + string name = 2; +} diff --git a/internal/tscommon/runtime.go b/internal/tscommon/runtime.go index 41b5029e..953f4045 100644 --- a/internal/tscommon/runtime.go +++ b/internal/tscommon/runtime.go @@ -1,6 +1,9 @@ package tscommon -import "strings" +import ( + "fmt" + "strings" +) // MessageRuntime selects how generated TypeScript represents protobuf messages. type MessageRuntime int @@ -30,3 +33,18 @@ func ParseMessageRuntime(param string) MessageRuntime { } return MessageRuntimeHandRolled } + +// UnsupportedEnumParamError builds the generation-time error returned when an +// enum-typed path or query parameter is encountered in protobuf-es runtime +// mode. protobuf-es represents enums as numeric values, but path/query +// parameters arrive as strings on the wire; safely bridging the two requires a +// name<->number conversion that is not yet implemented, so the generator fails +// loud here instead of emitting code that fails downstream `tsc`. paramKind is +// "path" or "query"; fieldName is the proto field name. +func UnsupportedEnumParamError(paramKind, fieldName, service, method string) error { + return fmt.Errorf( + "ts_runtime=protobuf-es: enum %s parameter %q on %s.%s is not yet supported "+ + "(protobuf-es enums are numeric; string<->enum conversion is not implemented)", + paramKind, fieldName, service, method, + ) +} diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index 81139f51..78945897 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -274,6 +274,17 @@ func (g *Generator) buildRPCRouteConfig(service *protogen.Service, method *proto return nil, fmt.Errorf("service %s, method %s: %w", serviceName, methodName, err) } + queryParams := annotations.GetQueryParams(method.Input) + + // Enum path/query parameters are not representable in protobuf-es mode + // (numeric enums vs string URL params); fail loud rather than emit code that + // fails downstream tsc. + if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { + if enumErr := checkNoEnumParamsES(serviceName, methodName, pathParamFields, queryParams); enumErr != nil { + return nil, enumErr + } + } + return &rpcRouteConfig{ serviceName: serviceName, methodName: methodName, @@ -281,11 +292,32 @@ func (g *Generator) buildRPCRouteConfig(service *protogen.Service, method *proto fullPath: fullPath, pathParams: pathParams, pathParamFields: pathParamFields, - queryParams: annotations.GetQueryParams(method.Input), + queryParams: queryParams, hasBody: httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH", }, nil } +// checkNoEnumParamsES reports a generation-time error if any path or query +// parameter is enum-typed (including repeated enum query params), which +// protobuf-es mode cannot yet represent. See UnsupportedEnumParamError. +func checkNoEnumParamsES( + service, method string, + pathParamFields []pathParamField, + queryParams []annotations.QueryParam, +) error { + for _, ppf := range pathParamFields { + if ppf.field != nil && ppf.field.Desc.Kind() == protoreflect.EnumKind { + return tscommon.UnsupportedEnumParamError("path", ppf.protoName, service, method) + } + } + for _, qp := range queryParams { + if qp.Field != nil && qp.Field.Desc.Kind() == protoreflect.EnumKind { + return tscommon.UnsupportedEnumParamError("query", qp.FieldName, service, method) + } + } + return nil +} + // resolvePathParamFields validates that every path parameter has a matching field // on the input message, and returns the proto→JSON name mapping. func resolvePathParamFields(pathParams []string, method *protogen.Method) ([]pathParamField, error) { @@ -582,9 +614,10 @@ func (g *Generator) emitPathParamAssignment( suffix string, ) { if ppf.field != nil && ppf.field.Desc.Kind() == protoreflect.EnumKind && ppf.field.Enum != nil { + // Hand-rolled mode only: enums are string unions here, so the cast is + // sound. protobuf-es mode rejects enum path params before emission (see + // checkNoEnumParamsES). enumName := g.ctx.RefEnum(ppf.field.Enum) - // TODO(es): enum path-param merge needs conversion, not a cast - // (protobuf-es enums are numeric; pathParams values are strings). p( "%s%s: pathParams[\"%s\"] as %s%s", prefix, ppf.jsonName, ppf.protoName, enumName, suffix, @@ -602,9 +635,9 @@ func (g *Generator) generatePathParamMerge(p tscommon.Printer, cfg *rpcRouteConf } for _, ppf := range cfg.pathParamFields { if ppf.field != nil && ppf.field.Desc.Kind() == protoreflect.EnumKind && ppf.field.Enum != nil { + // Hand-rolled mode only: protobuf-es mode rejects enum path params + // before emission (see checkNoEnumParamsES). enumName := g.ctx.RefEnum(ppf.field.Enum) - // TODO(es): enum path-param merge needs conversion, not a cast - // (protobuf-es enums are numeric; pathParams values are strings). p( " body.%s = pathParams[\"%s\"] as %s;", ppf.jsonName, ppf.protoName, enumName, diff --git a/internal/tsservergen/golden_test.go b/internal/tsservergen/golden_test.go index 3e3dc6ff..9819380f 100644 --- a/internal/tsservergen/golden_test.go +++ b/internal/tsservergen/golden_test.go @@ -225,6 +225,8 @@ func TestTSServerGenESGoldenFiles(t *testing.T) { // Server-streaming (SSE) fixture: each yielded event is encoded through // toJson(create(...)). Mirrors the hand-rolled SSE_streaming case. {name: "SSE streaming", protoFile: "sse.proto"}, + // Unary GET with a string path param and scalar (non-enum) query params. + {name: "unary GET path+query params", protoFile: "get_params.proto"}, } protoPaths := []string{"--proto_path=" + protoDir, "--proto_path=" + filepath.Join(projectRoot, "proto")} diff --git a/internal/tsservergen/inprocess_golden_test.go b/internal/tsservergen/inprocess_golden_test.go index 5594e502..4da8f05d 100644 --- a/internal/tsservergen/inprocess_golden_test.go +++ b/internal/tsservergen/inprocess_golden_test.go @@ -93,6 +93,33 @@ func TestTSServerGenInProcessValidationErrors(t *testing.T) { } } +// TestTSServerGenESRejectsEnumParams asserts that in protobuf-es mode the +// generator fails loud (rather than emitting uncompilable output) when a method +// has an enum-typed path or query parameter. query_params.proto has both an +// enum query param (SearchAdvanced) and an enum path param (GetByRegion). +func TestTSServerGenESRejectsEnumParams(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping in-process es enum-param test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + + plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{"query_params.proto"}) + genErr := New(plugin, tscommon.MessageRuntimeES).Generate() + if genErr == nil { + t.Fatal("expected Generate() to fail for enum path/query param in es mode, but it succeeded") + } + if !strings.Contains(genErr.Error(), "ts_runtime=protobuf-es: enum") || + !strings.Contains(genErr.Error(), "is not yet supported") { + t.Errorf("expected enum-param unsupported error, got: %v", genErr) + } +} + // TestTSServerGenInProcessReservedName drives a fixture whose message is named // ValidationError (a reserved error-helper symbol) and asserts Generate() fails // with a rename error, covering tscommon.CheckReservedNames via EmitSharedModules. diff --git a/internal/tsservergen/testdata/golden/es/get_params_pb.ts b/internal/tsservergen/testdata/golden/es/get_params_pb.ts new file mode 100644 index 00000000..f4ff0e35 --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/get_params_pb.ts @@ -0,0 +1,93 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file get_params.proto (package test.getparams, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file get_params.proto. + */ +export const file_get_params: GenFile = /*@__PURE__*/ + fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCIgCgRJdGVtEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkyfAoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAEaDaK1GAkKBy9hcGkvdjFCT1pNZ2l0aHViLmNvbS9TZWJhc3RpZW5NZWxraS9zZWJ1Zi9pbnRlcm5hbC9odHRwZ2VuL3Rlc3RkYXRhL2dlbmVyYXRlZDtnZW5lcmF0ZWRiBnByb3RvMw", [file_sebuf_http_annotations]); + +/** + * @generated from message test.getparams.GetItemRequest + */ +export type GetItemRequest = Message<"test.getparams.GetItemRequest"> & { + /** + * String path parameter. + * + * @generated from field: string item_id = 1; + */ + itemId: string; + + /** + * Scalar query parameters (no enum). + * + * @generated from field: string query = 2; + */ + query: string; + + /** + * @generated from field: int32 limit = 3; + */ + limit: number; + + /** + * @generated from field: bool include_archived = 4; + */ + includeArchived: boolean; +}; + +/** + * Describes the message test.getparams.GetItemRequest. + * Use `create(GetItemRequestSchema)` to create a new message. + */ +export const GetItemRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 0); + +/** + * @generated from message test.getparams.Item + */ +export type Item = Message<"test.getparams.Item"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message test.getparams.Item. + * Use `create(ItemSchema)` to create a new message. + */ +export const ItemSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 1); + +/** + * GetParamsService exercises a unary GET RPC with a string path parameter and + * scalar (non-enum) query parameters under protobuf-es mode. This locks down + * the path/query surface that the POST-unary and GET-streaming es fixtures do + * not cover. + * + * @generated from service test.getparams.GetParamsService + */ +export const GetParamsService: GenService<{ + /** + * @generated from rpc test.getparams.GetParamsService.GetItem + */ + getItem: { + methodKind: "unary"; + input: typeof GetItemRequestSchema; + output: typeof ItemSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_get_params, 0); + diff --git a/internal/tsservergen/testdata/golden/es/get_params_server.ts b/internal/tsservergen/testdata/golden/es/get_params_server.ts new file mode 100644 index 00000000..74501b14 --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/get_params_server.ts @@ -0,0 +1,90 @@ +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: get_params.proto + +import { create, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { FieldViolation, ValidationError } from "./errors.js"; +import { GetItemRequestSchema, ItemSchema } from "./get_params_pb.js"; +import type { GetItemRequest } from "./get_params_pb.js"; + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface GetParamsServiceHandler { + getItem(ctx: ServerContext, req: GetItemRequest): Promise>; +} + +export function createGetParamsServiceRoutes( + handler: GetParamsServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "GET", + path: "/api/v1/items/{item_id}", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const pathSegments = url.pathname.split("/"); + pathParams["item_id"] = decodeURIComponent(pathSegments[4] ?? ""); + + const params = url.searchParams; + const body = create(GetItemRequestSchema, { + itemId: pathParams["item_id"], + query: params.get("q") ?? "", + limit: Number(params.get("limit") ?? "0"), + includeArchived: params.get("include_archived") === "true", + }); + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getItem", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getItem(ctx, body); + return new Response(JSON.stringify(toJson(ItemSchema, create(ItemSchema, result))), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + diff --git a/internal/tsservergen/testdata/proto/get_params.proto b/internal/tsservergen/testdata/proto/get_params.proto new file mode 100644 index 00000000..e73ebe2b --- /dev/null +++ b/internal/tsservergen/testdata/proto/get_params.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package test.getparams; + +option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/generated;generated"; + +import "sebuf/http/annotations.proto"; + +// GetParamsService exercises a unary GET RPC with a string path parameter and +// scalar (non-enum) query parameters under protobuf-es mode. This locks down +// the path/query surface that the POST-unary and GET-streaming es fixtures do +// not cover. +service GetParamsService { + option (sebuf.http.service_config) = { + base_path: "/api/v1" + }; + + rpc GetItem(GetItemRequest) returns (Item) { + option (sebuf.http.config) = { + path: "/items/{item_id}" + method: HTTP_METHOD_GET + }; + } +} + +message GetItemRequest { + // String path parameter. + string item_id = 1; + // Scalar query parameters (no enum). + string query = 2 [(sebuf.http.query) = { name: "q" }]; + int32 limit = 3 [(sebuf.http.query) = { name: "limit" }]; + bool include_archived = 4 [(sebuf.http.query) = { name: "include_archived" }]; +} + +message Item { + string id = 1; + string name = 2; +} From 8c1e4c19eba75b61fa08f97dc9861e0d6d3ccc70 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:43:36 +0300 Subject: [PATCH 11/15] fix(ts): record protobuf-es runtime imports at emission sites The @bufbuild/protobuf import list was derived by regex word-boundary scanning of the generated body, which had two failure modes: - an RPC named Create renders `async create(`, injecting `import { create } from "@bufbuild/protobuf"` into HAND-ROLLED output, whose consumers do not install that package; - a field named create in an es-mode GET method (`req.create`) imported the helper unused, failing consumers under noUnusedLocals. Each es-mode emission site now records exactly the helpers it prints (MessageInitShape/fromJson per method; create/toJson only when a body or response is encoded; create for the server GET init wrap), the body scan is deleted, and EmitContext.NeedProtobufES is a no-op outside protobuf-es runtime mode as a structural invariant. Existing goldens are byte-identical; regression tests cover both failure modes. Co-Authored-By: Claude Fable 5 --- internal/tsclientgen/generator.go | 8 ++ internal/tsclientgen/modules.go | 6 +- internal/tsclientgen/runtime_symbols_test.go | 92 +++++++++++++++++++ .../testdata/proto/runtime_symbol_field.proto | 37 ++++++++ .../testdata/proto/runtime_symbol_rpc.proto | 33 +++++++ internal/tscommon/imports.go | 37 +------- internal/tsservergen/generator.go | 5 + internal/tsservergen/modules.go | 6 +- internal/tsservergen/runtime_symbols_test.go | 39 ++++++++ .../testdata/proto/runtime_symbol_rpc.proto | 33 +++++++ 10 files changed, 258 insertions(+), 38 deletions(-) create mode 100644 internal/tsclientgen/runtime_symbols_test.go create mode 100644 internal/tsclientgen/testdata/proto/runtime_symbol_field.proto create mode 100644 internal/tsclientgen/testdata/proto/runtime_symbol_rpc.proto create mode 100644 internal/tsservergen/runtime_symbols_test.go create mode 100644 internal/tsservergen/testdata/proto/runtime_symbol_rpc.proto diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index 1c13c5d6..3bfef799 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -270,6 +270,10 @@ func (g *Generator) generateRPCMethod(p printer, service *protogen.Service, meth resSchema = g.ctx.RefMessageSchema(method.Output) inputType = "MessageInitShape" outputType = g.ctx.RefMessagePb(method.Output) + g.ctx.NeedProtobufES("MessageInitShape", "fromJson") + if cfg.hasBody { + g.ctx.NeedProtobufES("create", "toJson") + } } else { inputType = g.ctx.RefMessage(method.Input) outputType = g.resolveOutputType(method) @@ -315,6 +319,10 @@ func (g *Generator) generateSSERPCMethod( resSchema = g.ctx.RefMessageSchema(method.Output) inputType = "MessageInitShape" outputType = g.ctx.RefMessagePb(method.Output) + g.ctx.NeedProtobufES("MessageInitShape", "fromJson") + if cfg.hasBody { + g.ctx.NeedProtobufES("create", "toJson") + } } else { inputType = g.ctx.RefMessage(method.Input) outputType = g.resolveOutputType(method) diff --git a/internal/tsclientgen/modules.go b/internal/tsclientgen/modules.go index e0be5e26..fa006342 100644 --- a/internal/tsclientgen/modules.go +++ b/internal/tsclientgen/modules.go @@ -47,10 +47,10 @@ func (g *Generator) emitClientModule(file *protogen.File) (string, error) { return "", err } } - // Import only the error helpers actually referenced in the body. + // Import only the error helpers actually referenced in the body. The + // protobuf-es runtime symbols need no scan: es-mode emission sites record + // them via NeedProtobufES as they print. g.ctx.NeedErrors(tscommon.UsedErrorSymbols(body)...) - // Import only the protobuf-es runtime symbols actually referenced. - g.ctx.NeedProtobufES(tscommon.UsedProtobufESSymbols(body)...) dp := tscommon.DirectPrinter(gf) dp("// Code generated by protoc-gen-ts-client. DO NOT EDIT.") diff --git a/internal/tsclientgen/runtime_symbols_test.go b/internal/tsclientgen/runtime_symbols_test.go new file mode 100644 index 00000000..3308a55e --- /dev/null +++ b/internal/tsclientgen/runtime_symbols_test.go @@ -0,0 +1,92 @@ +package tsclientgen + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/SebastienMelki/sebuf/internal/tscommon" +) + +// TestTSClientGenHandRolledNeverImportsProtobufES asserts that hand-rolled +// output never imports @bufbuild/protobuf, even when an RPC is literally named +// Create (rendering `async create(` in the body). Hand-rolled consumers do not +// install the protobuf-es runtime, so any such import breaks their build. +func TestTSClientGenHandRolledNeverImportsProtobufES(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping in-process runtime-symbol test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + + plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{"runtime_symbol_rpc.proto"}) + if genErr := New(plugin, tscommon.MessageRuntimeHandRolled).Generate(); genErr != nil { + t.Fatalf("Generate() failed: %v", genErr) + } + for _, f := range plugin.Response().GetFile() { + if strings.Contains(f.GetContent(), "@bufbuild/protobuf") { + t.Errorf("hand-rolled output %s imports @bufbuild/protobuf:\n%s", f.GetName(), f.GetContent()) + } + } +} + +// TestTSClientGenESImportsOnlyUsedRuntimeSymbols asserts that an es-mode client +// imports only the protobuf-es helpers it actually calls. The fixture is a +// GET-only method whose query field is named create, so `req.create` appears in +// the body; the import must still be just fromJson + MessageInitShape — an +// unused `create` import fails consumers compiling with noUnusedLocals. +func TestTSClientGenESImportsOnlyUsedRuntimeSymbols(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping in-process runtime-symbol test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + + plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{"runtime_symbol_field.proto"}) + if genErr := New(plugin, tscommon.MessageRuntimeES).Generate(); genErr != nil { + t.Fatalf("Generate() failed: %v", genErr) + } + + var clientContent string + for _, f := range plugin.Response().GetFile() { + if strings.HasSuffix(f.GetName(), "_client.ts") { + clientContent = f.GetContent() + } + } + if clientContent == "" { + t.Fatal("generator emitted no _client.ts file") + } + + var importLine string + for _, line := range strings.Split(clientContent, "\n") { + if strings.Contains(line, `from "@bufbuild/protobuf"`) { + importLine = line + break + } + } + if importLine == "" { + t.Fatalf("es-mode client has no @bufbuild/protobuf import:\n%s", clientContent) + } + for _, unused := range []string{"create", "toJson"} { + if strings.Contains(importLine, unused) { + t.Errorf("GET-only es client imports unused runtime symbol %q: %s", unused, importLine) + } + } + for _, used := range []string{"fromJson", "MessageInitShape"} { + if !strings.Contains(importLine, used) { + t.Errorf("es client import is missing %q: %s", used, importLine) + } + } +} diff --git a/internal/tsclientgen/testdata/proto/runtime_symbol_field.proto b/internal/tsclientgen/testdata/proto/runtime_symbol_field.proto new file mode 100644 index 00000000..6e5756f2 --- /dev/null +++ b/internal/tsclientgen/testdata/proto/runtime_symbol_field.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package test.runtimesymbolfield; + +option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/generated;generated"; + +import "sebuf/http/annotations.proto"; + +// FieldNamingService is a regression fixture: a query parameter field literally +// named create appears in the generated body as `req.create`, which must not +// trick the generator into importing the protobuf-es runtime helper `create`. +// The method is GET-only, so an es-mode client legitimately uses only fromJson +// and MessageInitShape; importing `create` would fail noUnusedLocals. +service FieldNamingService { + option (sebuf.http.service_config) = { + base_path: "/api/v1" + }; + + rpc ListThings(ListThingsRequest) returns (ListThingsResponse) { + option (sebuf.http.config) = { + path: "/things" + method: HTTP_METHOD_GET + }; + } +} + +message ListThingsRequest { + string create = 1 [(sebuf.http.query) = {name: "create"}]; +} + +message ListThingsResponse { + repeated Thing things = 1; +} + +message Thing { + string id = 1; +} diff --git a/internal/tsclientgen/testdata/proto/runtime_symbol_rpc.proto b/internal/tsclientgen/testdata/proto/runtime_symbol_rpc.proto new file mode 100644 index 00000000..07d32126 --- /dev/null +++ b/internal/tsclientgen/testdata/proto/runtime_symbol_rpc.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package test.runtimesymbols; + +option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/generated;generated"; + +import "sebuf/http/annotations.proto"; + +// CreateNamingService is a regression fixture: an RPC literally named Create +// renders a TypeScript method `create(`, which must never be mistaken for the +// protobuf-es runtime helper `create` when deciding whether the generated file +// imports from @bufbuild/protobuf. Hand-rolled output must not import that +// package at all. +service CreateNamingService { + option (sebuf.http.service_config) = { + base_path: "/api/v1" + }; + + rpc Create(CreateThingRequest) returns (CreateThingResponse) { + option (sebuf.http.config) = { + path: "/things" + method: HTTP_METHOD_POST + }; + } +} + +message CreateThingRequest { + string name = 1; +} + +message CreateThingResponse { + string id = 1; +} diff --git a/internal/tscommon/imports.go b/internal/tscommon/imports.go index 737b9154..81f15b1f 100644 --- a/internal/tscommon/imports.go +++ b/internal/tscommon/imports.go @@ -3,7 +3,6 @@ package tscommon import ( "fmt" "path" - "regexp" "sort" "strings" @@ -301,9 +300,12 @@ func (c *EmitContext) RefMessageSchema(msg *protogen.Message) string { } // NeedProtobufES records that the file references the given protobuf-es runtime -// helpers (a subset of create/fromJson/toJson/MessageInitShape). +// helpers (a subset of create/fromJson/toJson/MessageInitShape). Generators call +// it at each es-mode emission site, so the import list mirrors actual usage; it +// is a no-op outside protobuf-es runtime mode — hand-rolled output must never +// import @bufbuild/protobuf, whatever the emitted code happens to contain. func (c *EmitContext) NeedProtobufES(symbols ...string) { - if !c.modules() || len(symbols) == 0 { + if c.MessageRuntime != MessageRuntimeES || !c.modules() || len(symbols) == 0 { return } c.Imports.NeedProtobufES(symbols...) @@ -317,35 +319,6 @@ func (c *EmitContext) NeedErrors(symbols ...string) { c.Imports.NeedErrors(RelativeImportSpecifier(c.SelfModule, errorsModule), symbols...) } -// protobufESSymbolPatterns holds the word-boundary matcher for each protobuf-es -// runtime symbol, so a match on `create` is not triggered by a longer -// identifier that merely contains it. -var protobufESSymbolPatterns = func() map[string]*regexp.Regexp { - m := make(map[string]*regexp.Regexp, len(protobufESSymbolOrder)) - for _, s := range protobufESSymbolOrder { - m[s] = regexp.MustCompile(`\b` + regexp.QuoteMeta(s) + `\b`) - } - return m -}() - -// UsedProtobufESSymbols returns the protobuf-es runtime symbols -// (create/fromJson/toJson/MessageInitShape) referenced anywhere in the given -// body lines, in canonical import order, using word-boundary matching so a -// symbol is not matched inside a longer identifier. -func UsedProtobufESSymbols(lines []string) []string { - var used []string - for _, sym := range protobufESSymbolOrder { - re := protobufESSymbolPatterns[sym] - for _, line := range lines { - if re.MatchString(line) { - used = append(used, sym) - break - } - } - } - return used -} - // UsedErrorSymbols returns the error-helper symbols referenced anywhere in the // given body lines, so a generated file imports only what it uses (these // symbols are distinct, none a substring of another). diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index 78945897..190f41b5 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -204,6 +204,7 @@ func (g *Generator) generateHandlerInterface(p tscommon.Printer, service *protog if es { inputType = g.ctx.RefMessagePb(method.Input) outputType = "MessageInitShape" + g.ctx.NeedProtobufES("MessageInitShape") } else { inputType = g.ctx.RefMessage(method.Input) outputType = g.resolveOutputType(method) @@ -464,6 +465,7 @@ func (g *Generator) generateRouteEntry(p tscommon.Printer, service *protogen.Ser // canonical protojson encoding (Go-server parity); otherwise it is raw-cast. if es { resSchema := g.ctx.RefMessageSchema(method.Output) + g.ctx.NeedProtobufES("create", "toJson") p(" return new Response(JSON.stringify(toJson(%s, create(%s, result))), {", resSchema, resSchema) } else { outputType := g.resolveOutputType(method) @@ -559,6 +561,7 @@ func (g *Generator) generateSSERouteEntry( valueExpr := "value" if es { resSchema := g.ctx.RefMessageSchema(method.Output) + g.ctx.NeedProtobufES("create", "toJson") valueExpr = fmt.Sprintf("toJson(%s, create(%s, value))", resSchema, resSchema) } p(" controller.enqueue(encoder.encode(`data: ${JSON.stringify(%s)}\\n\\n`));", valueExpr) @@ -715,6 +718,7 @@ func (g *Generator) generatePathParamExtraction(p tscommon.Printer, cfg *rpcRout func (g *Generator) generateBodyParsing(p tscommon.Printer, method *protogen.Method, tsMethodName string) { if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { reqSchema := g.ctx.RefMessageSchema(method.Input) + g.ctx.NeedProtobufES("fromJson") p(" const body = fromJson(%s, await req.json(), { ignoreUnknownFields: true });", reqSchema) } else { inputType := g.ctx.RefMessage(method.Input) @@ -745,6 +749,7 @@ func (g *Generator) generateQueryParamParsing( var inputType, reqSchema string if es { reqSchema = g.ctx.RefMessageSchema(method.Input) + g.ctx.NeedProtobufES("create") } else { inputType = g.ctx.RefMessage(method.Input) } diff --git a/internal/tsservergen/modules.go b/internal/tsservergen/modules.go index 1658fe85..11f80e72 100644 --- a/internal/tsservergen/modules.go +++ b/internal/tsservergen/modules.go @@ -52,10 +52,10 @@ func (g *Generator) emitServerModule(file *protogen.File) (string, error) { return "", err } } - // Import only the error helpers actually referenced in the body. + // Import only the error helpers actually referenced in the body. The + // protobuf-es runtime symbols need no scan: es-mode emission sites record + // them via NeedProtobufES as they print. g.ctx.NeedErrors(tscommon.UsedErrorSymbols(body)...) - // Import only the protobuf-es runtime symbols actually referenced. - g.ctx.NeedProtobufES(tscommon.UsedProtobufESSymbols(body)...) dp := tscommon.DirectPrinter(gf) dp("// Code generated by protoc-gen-ts-server. DO NOT EDIT.") diff --git a/internal/tsservergen/runtime_symbols_test.go b/internal/tsservergen/runtime_symbols_test.go new file mode 100644 index 00000000..a02eedd9 --- /dev/null +++ b/internal/tsservergen/runtime_symbols_test.go @@ -0,0 +1,39 @@ +package tsservergen + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/SebastienMelki/sebuf/internal/tscommon" +) + +// TestTSServerGenHandRolledNeverImportsProtobufES asserts that hand-rolled +// output never imports @bufbuild/protobuf, even when an RPC is literally named +// Create (rendering a handler method `create(` in the body). Hand-rolled +// consumers do not install the protobuf-es runtime, so any such import breaks +// their build. +func TestTSServerGenHandRolledNeverImportsProtobufES(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping in-process runtime-symbol test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + + plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{"runtime_symbol_rpc.proto"}) + if genErr := New(plugin, tscommon.MessageRuntimeHandRolled).Generate(); genErr != nil { + t.Fatalf("Generate() failed: %v", genErr) + } + for _, f := range plugin.Response().GetFile() { + if strings.Contains(f.GetContent(), "@bufbuild/protobuf") { + t.Errorf("hand-rolled output %s imports @bufbuild/protobuf:\n%s", f.GetName(), f.GetContent()) + } + } +} diff --git a/internal/tsservergen/testdata/proto/runtime_symbol_rpc.proto b/internal/tsservergen/testdata/proto/runtime_symbol_rpc.proto new file mode 100644 index 00000000..07d32126 --- /dev/null +++ b/internal/tsservergen/testdata/proto/runtime_symbol_rpc.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package test.runtimesymbols; + +option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/generated;generated"; + +import "sebuf/http/annotations.proto"; + +// CreateNamingService is a regression fixture: an RPC literally named Create +// renders a TypeScript method `create(`, which must never be mistaken for the +// protobuf-es runtime helper `create` when deciding whether the generated file +// imports from @bufbuild/protobuf. Hand-rolled output must not import that +// package at all. +service CreateNamingService { + option (sebuf.http.service_config) = { + base_path: "/api/v1" + }; + + rpc Create(CreateThingRequest) returns (CreateThingResponse) { + option (sebuf.http.config) = { + path: "/things" + method: HTTP_METHOD_POST + }; + } +} + +message CreateThingRequest { + string name = 1; +} + +message CreateThingResponse { + string id = 1; +} From 96b33c97daeb22a092e0cca1ad64bc4e9da933c1 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:43:53 +0300 Subject: [PATCH 12/15] test(ts): typecheck es goldens with tsc --noEmit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-comparing goldens locks down what sebuf emits but not that it compiles against the real @bufbuild/protobuf types and the symbols protoc-gen-es actually exported — the check that catches an ESQualifiedName naming divergence in CI instead of a consumer build. Ports the tscommon/typecheck harness (strict nodenext + noUnusedLocals, tsc from PATH or pinned via npx, skips when unavailable) and runs it over testdata/golden/es in both generators, resolving @bufbuild/protobuf through the same git-ignored node_modules discovery the wire-conformance test uses (SEBUF_ES_NODE_MODULES override supported). Also checks in the transitive sebuf/http/annotations_pb.ts goldens the fixtures' *_pb.ts import — without them the es golden tree never typechecked (TS2307 on ./sebuf/http/annotations_pb.js). Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + internal/tsclientgen/es_typecheck_test.go | 55 ++ .../golden/es/sebuf/http/annotations_pb.ts | 564 ++++++++++++++++++ internal/tscommon/typecheck/typecheck.go | 81 +++ internal/tsservergen/es_typecheck_test.go | 55 ++ .../golden/es/sebuf/http/annotations_pb.ts | 564 ++++++++++++++++++ 6 files changed, 1324 insertions(+) create mode 100644 internal/tsclientgen/es_typecheck_test.go create mode 100644 internal/tsclientgen/testdata/golden/es/sebuf/http/annotations_pb.ts create mode 100644 internal/tscommon/typecheck/typecheck.go create mode 100644 internal/tsservergen/es_typecheck_test.go create mode 100644 internal/tsservergen/testdata/golden/es/sebuf/http/annotations_pb.ts diff --git a/.gitignore b/.gitignore index cc5d91c1..128c24b8 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,8 @@ internal/modules/*/internal/infrastructure/persistence/sqlc/ # node_modules symlink created by the protobuf-es wire-conformance test # (internal/tsclientgen/conformance_test.go) so node can resolve @bufbuild/protobuf internal/tsclientgen/testdata/es/node_modules + +# node_modules symlinks created by the es golden typecheck tests +# (es_typecheck_test.go) so tsc can resolve @bufbuild/protobuf +internal/tsclientgen/testdata/golden/es/node_modules +internal/tsservergen/testdata/golden/es/node_modules diff --git a/internal/tsclientgen/es_typecheck_test.go b/internal/tsclientgen/es_typecheck_test.go new file mode 100644 index 00000000..e0679e30 --- /dev/null +++ b/internal/tsclientgen/es_typecheck_test.go @@ -0,0 +1,55 @@ +package tsclientgen + +import ( + "os" + "path/filepath" + "testing" + + "github.com/SebastienMelki/sebuf/internal/tscommon/typecheck" +) + +// TestTSClientGenESGoldenTypecheck runs tsc --noEmit over the checked-in es +// goldens (testdata/golden/es). Byte-comparing goldens locks down what sebuf +// emits; this locks down that it compiles against the real @bufbuild/protobuf +// types and the symbols protoc-gen-es actually exported — the machine check +// that ESQualifiedName still matches protoc-gen-es naming. Skips (never fails) +// when tsc/npx or a @bufbuild/protobuf install is unavailable. +func TestTSClientGenESGoldenTypecheck(t *testing.T) { + goldenES := filepath.Join("testdata", "golden", "es") + linkESNodeModules(t, goldenES) + typecheck.Dir(t, goldenES) +} + +// linkESNodeModules symlinks a node_modules that resolves @bufbuild/protobuf +// into dir so tsc (nodenext resolution) can resolve the runtime imports. It +// mirrors the discovery in conformance_test.go: SEBUF_ES_NODE_MODULES overrides, +// else the git-ignored .scratch/es-spike install; skips when neither exists. +// The symlink is created only if absent and removed on cleanup, leaving a +// developer's own symlink untouched. +func linkESNodeModules(t *testing.T, dir string) { + t.Helper() + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + nodeModules := os.Getenv("SEBUF_ES_NODE_MODULES") + if nodeModules == "" { + nodeModules = filepath.Join(baseDir, "..", "..", ".scratch", "es-spike", "node_modules") + } + if _, statErr := os.Stat(filepath.Join(nodeModules, "@bufbuild", "protobuf", "package.json")); statErr != nil { + t.Skipf("@bufbuild/protobuf not found under %s, skipping es typecheck", nodeModules) + } + absNodeModules, err := filepath.Abs(nodeModules) + if err != nil { + t.Fatalf("Failed to resolve node_modules path: %v", err) + } + + linkPath := filepath.Join(dir, "node_modules") + if _, linkErr := os.Lstat(linkPath); os.IsNotExist(linkErr) { + if symErr := os.Symlink(absNodeModules, linkPath); symErr != nil { + t.Fatalf("Failed to symlink node_modules for es typecheck: %v", symErr) + } + t.Cleanup(func() { _ = os.Remove(linkPath) }) + } +} diff --git a/internal/tsclientgen/testdata/golden/es/sebuf/http/annotations_pb.ts b/internal/tsclientgen/testdata/golden/es/sebuf/http/annotations_pb.ts new file mode 100644 index 00000000..7bc2fbd7 --- /dev/null +++ b/internal/tsclientgen/testdata/golden/es/sebuf/http/annotations_pb.ts @@ -0,0 +1,564 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file sebuf/http/annotations.proto (package sebuf.http, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenExtension, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, extDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { EnumValueOptions, FieldOptions, MethodOptions, OneofOptions, ServiceOptions } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_descriptor } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file sebuf/http/annotations.proto. + */ +export const file_sebuf_http_annotations: GenFile = /*@__PURE__*/ + fileDesc("ChxzZWJ1Zi9odHRwL2Fubm90YXRpb25zLnByb3RvEgpzZWJ1Zi5odHRwIlIKCkh0dHBDb25maWcSDAoEcGF0aBgBIAEoCRImCgZtZXRob2QYAiABKA4yFi5zZWJ1Zi5odHRwLkh0dHBNZXRob2QSDgoGc3RyZWFtGAMgASgIIiIKDVNlcnZpY2VDb25maWcSEQoJYmFzZV9wYXRoGAEgASgJIh8KDUZpZWxkRXhhbXBsZXMSDgoGdmFsdWVzGAEgAygJIi0KC1F1ZXJ5Q29uZmlnEgwKBG5hbWUYASABKAkSEAoIcmVxdWlyZWQYAiABKAgiNQoLT25lb2ZDb25maWcSFQoNZGlzY3JpbWluYXRvchgBIAEoCRIPCgdmbGF0dGVuGAIgASgIKpgBCgpIdHRwTWV0aG9kEhsKF0hUVFBfTUVUSE9EX1VOU1BFQ0lGSUVEEAASEwoPSFRUUF9NRVRIT0RfR0VUEAESFAoQSFRUUF9NRVRIT0RfUE9TVBACEhMKD0hUVFBfTUVUSE9EX1BVVBADEhYKEkhUVFBfTUVUSE9EX0RFTEVURRAEEhUKEUhUVFBfTUVUSE9EX1BBVENIEAUqZQoNSW50NjRFbmNvZGluZxIeChpJTlQ2NF9FTkNPRElOR19VTlNQRUNJRklFRBAAEhkKFUlOVDY0X0VOQ09ESU5HX1NUUklORxABEhkKFUlOVDY0X0VOQ09ESU5HX05VTUJFUhACKmEKDEVudW1FbmNvZGluZxIdChlFTlVNX0VOQ09ESU5HX1VOU1BFQ0lGSUVEEAASGAoURU5VTV9FTkNPRElOR19TVFJJTkcQARIYChRFTlVNX0VOQ09ESU5HX05VTUJFUhACKn4KDUVtcHR5QmVoYXZpb3ISHgoaRU1QVFlfQkVIQVZJT1JfVU5TUEVDSUZJRUQQABIbChdFTVBUWV9CRUhBVklPUl9QUkVTRVJWRRABEhcKE0VNUFRZX0JFSEFWSU9SX05VTEwQAhIXChNFTVBUWV9CRUhBVklPUl9PTUlUEAMqsQEKD1RpbWVzdGFtcEZvcm1hdBIgChxUSU1FU1RBTVBfRk9STUFUX1VOU1BFQ0lGSUVEEAASHAoYVElNRVNUQU1QX0ZPUk1BVF9SRkMzMzM5EAESIQodVElNRVNUQU1QX0ZPUk1BVF9VTklYX1NFQ09ORFMQAhIgChxUSU1FU1RBTVBfRk9STUFUX1VOSVhfTUlMTElTEAMSGQoVVElNRVNUQU1QX0ZPUk1BVF9EQVRFEAQqwQEKDUJ5dGVzRW5jb2RpbmcSHgoaQllURVNfRU5DT0RJTkdfVU5TUEVDSUZJRUQQABIZChVCWVRFU19FTkNPRElOR19CQVNFNjQQARIdChlCWVRFU19FTkNPRElOR19CQVNFNjRfUkFXEAISHAoYQllURVNfRU5DT0RJTkdfQkFTRTY0VVJMEAMSIAocQllURVNfRU5DT0RJTkdfQkFTRTY0VVJMX1JBVxAEEhYKEkJZVEVTX0VOQ09ESU5HX0hFWBAFOlAKBmNvbmZpZxIeLmdvb2dsZS5wcm90b2J1Zi5NZXRob2RPcHRpb25zGNOGAyABKAsyFi5zZWJ1Zi5odHRwLkh0dHBDb25maWdSBmNvbmZpZzpjCg5zZXJ2aWNlX2NvbmZpZxIfLmdvb2dsZS5wcm90b2J1Zi5TZXJ2aWNlT3B0aW9ucxjUhgMgASgLMhkuc2VidWYuaHR0cC5TZXJ2aWNlQ29uZmlnUg1zZXJ2aWNlQ29uZmlnOl4KDG9uZW9mX2NvbmZpZxIdLmdvb2dsZS5wcm90b2J1Zi5PbmVvZk9wdGlvbnMY4YYDIAEoCzIXLnNlYnVmLmh0dHAuT25lb2ZDb25maWdSC29uZW9mQ29uZmlniAEBOmEKDmZpZWxkX2V4YW1wbGVzEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjXhgMgASgLMhkuc2VidWYuaHR0cC5GaWVsZEV4YW1wbGVzUg1maWVsZEV4YW1wbGVzOk4KBXF1ZXJ5Eh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjYhgMgASgLMhcuc2VidWYuaHR0cC5RdWVyeUNvbmZpZ1IFcXVlcnk6NwoGdW53cmFwEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjZhgMgASgIUgZ1bndyYXA6ZAoOaW50NjRfZW5jb2RpbmcSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGNqGAyABKA4yGS5zZWJ1Zi5odHRwLkludDY0RW5jb2RpbmdSDWludDY0RW5jb2RpbmeIAQE6YQoNZW51bV9lbmNvZGluZxIdLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMY24YDIAEoDjIYLnNlYnVmLmh0dHAuRW51bUVuY29kaW5nUgxlbnVtRW5jb2RpbmeIAQE6PgoIbnVsbGFibGUSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGN2GAyABKAhSCG51bGxhYmxliAEBOmQKDmVtcHR5X2JlaGF2aW9yEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjehgMgASgOMhkuc2VidWYuaHR0cC5FbXB0eUJlaGF2aW9yUg1lbXB0eUJlaGF2aW9yiAEBOmoKEHRpbWVzdGFtcF9mb3JtYXQSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGN+GAyABKA4yGy5zZWJ1Zi5odHRwLlRpbWVzdGFtcEZvcm1hdFIPdGltZXN0YW1wRm9ybWF0iAEBOmQKDmJ5dGVzX2VuY29kaW5nEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjghgMgASgOMhkuc2VidWYuaHR0cC5CeXRlc0VuY29kaW5nUg1ieXRlc0VuY29kaW5niAEBOkMKC29uZW9mX3ZhbHVlEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjihgMgASgJUgpvbmVvZlZhbHVliAEBOjwKB2ZsYXR0ZW4SHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGOOGAyABKAhSB2ZsYXR0ZW6IAQE6SQoOZmxhdHRlbl9wcmVmaXgSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGOSGAyABKAlSDWZsYXR0ZW5QcmVmaXiIAQE6RQoKZW51bV92YWx1ZRIhLmdvb2dsZS5wcm90b2J1Zi5FbnVtVmFsdWVPcHRpb25zGNyGAyABKAlSCWVudW1WYWx1ZYgBAUIrWilnaXRodWIuY29tL1NlYmFzdGllbk1lbGtpL3NlYnVmL2h0dHA7aHR0cGIGcHJvdG8z", [file_google_protobuf_descriptor]); + +/** + * HttpConfig defines HTTP-specific configuration for an RPC method + * + * @generated from message sebuf.http.HttpConfig + */ +export type HttpConfig = Message<"sebuf.http.HttpConfig"> & { + /** + * The HTTP path for this method (supports path variables like /users/{id}) + * + * @generated from field: string path = 1; + */ + path: string; + + /** + * The HTTP method (GET, POST, PUT, DELETE, PATCH). Defaults to POST if unspecified. + * + * @generated from field: sebuf.http.HttpMethod method = 2; + */ + method: HttpMethod; + + /** + * When true, this method uses Server-Sent Events (SSE) for streaming responses. + * The server sends events with Content-Type: text/event-stream. + * Each event is the response message serialized as JSON in the SSE data field. + * + * @generated from field: bool stream = 3; + */ + stream: boolean; +}; + +/** + * Describes the message sebuf.http.HttpConfig. + * Use `create(HttpConfigSchema)` to create a new message. + */ +export const HttpConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 0); + +/** + * ServiceConfig defines HTTP-specific configuration for an entire service + * + * @generated from message sebuf.http.ServiceConfig + */ +export type ServiceConfig = Message<"sebuf.http.ServiceConfig"> & { + /** + * Base path prefix for all methods in this service + * + * @generated from field: string base_path = 1; + */ + basePath: string; +}; + +/** + * Describes the message sebuf.http.ServiceConfig. + * Use `create(ServiceConfigSchema)` to create a new message. + */ +export const ServiceConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 1); + +/** + * FieldExamples defines example values for a field + * + * @generated from message sebuf.http.FieldExamples + */ +export type FieldExamples = Message<"sebuf.http.FieldExamples"> & { + /** + * List of example values for this field + * + * @generated from field: repeated string values = 1; + */ + values: string[]; +}; + +/** + * Describes the message sebuf.http.FieldExamples. + * Use `create(FieldExamplesSchema)` to create a new message. + */ +export const FieldExamplesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 2); + +/** + * QueryConfig defines query parameter configuration for a message field + * + * @generated from message sebuf.http.QueryConfig + */ +export type QueryConfig = Message<"sebuf.http.QueryConfig"> & { + /** + * The query parameter name in the URL (e.g., "page_size" for ?page_size=10) + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Whether this query parameter is required + * + * @generated from field: bool required = 2; + */ + required: boolean; +}; + +/** + * Describes the message sebuf.http.QueryConfig. + * Use `create(QueryConfigSchema)` to create a new message. + */ +export const QueryConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 3); + +/** + * OneofConfig controls oneof serialization as a discriminated union. + * Applied to a oneof definition via (sebuf.http.oneof_config). + * + * @generated from message sebuf.http.OneofConfig + */ +export type OneofConfig = Message<"sebuf.http.OneofConfig"> & { + /** + * The JSON field name for the discriminator (e.g., "type", "kind"). + * Required: if empty, the annotation is ignored. + * + * @generated from field: string discriminator = 1; + */ + discriminator: string; + + /** + * Whether to flatten variant message fields to the same level as the discriminator. + * When true: variant's child fields are promoted to the parent object alongside the discriminator. + * When false: variant stays nested under its field name with the discriminator alongside. + * Requires all variant fields to be message types when true. + * + * @generated from field: bool flatten = 2; + */ + flatten: boolean; +}; + +/** + * Describes the message sebuf.http.OneofConfig. + * Use `create(OneofConfigSchema)` to create a new message. + */ +export const OneofConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 4); + +/** + * HttpMethod specifies the HTTP verb for an RPC method + * + * @generated from enum sebuf.http.HttpMethod + */ +export enum HttpMethod { + /** + * Unspecified defaults to POST for backward compatibility + * + * @generated from enum value: HTTP_METHOD_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: HTTP_METHOD_GET = 1; + */ + GET = 1, + + /** + * @generated from enum value: HTTP_METHOD_POST = 2; + */ + POST = 2, + + /** + * @generated from enum value: HTTP_METHOD_PUT = 3; + */ + PUT = 3, + + /** + * @generated from enum value: HTTP_METHOD_DELETE = 4; + */ + DELETE = 4, + + /** + * @generated from enum value: HTTP_METHOD_PATCH = 5; + */ + PATCH = 5, +} + +/** + * Describes the enum sebuf.http.HttpMethod. + */ +export const HttpMethodSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 0); + +/** + * Int64Encoding controls how int64/uint64 fields serialize to JSON + * + * @generated from enum sebuf.http.Int64Encoding + */ +export enum Int64Encoding { + /** + * Follow protojson default (serialize as string) + * + * @generated from enum value: INT64_ENCODING_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Explicit string encoding: "12345" + * + * @generated from enum value: INT64_ENCODING_STRING = 1; + */ + STRING = 1, + + /** + * Numeric encoding: 12345 (precision warning for values > 2^53) + * + * @generated from enum value: INT64_ENCODING_NUMBER = 2; + */ + NUMBER = 2, +} + +/** + * Describes the enum sebuf.http.Int64Encoding. + */ +export const Int64EncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 1); + +/** + * EnumEncoding controls how enum fields serialize to JSON + * + * @generated from enum sebuf.http.EnumEncoding + */ +export enum EnumEncoding { + /** + * Follow protojson default (serialize as proto name string) + * + * @generated from enum value: ENUM_ENCODING_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Explicit string encoding: "STATUS_ACTIVE" + * + * @generated from enum value: ENUM_ENCODING_STRING = 1; + */ + STRING = 1, + + /** + * Numeric encoding: 1 + * + * @generated from enum value: ENUM_ENCODING_NUMBER = 2; + */ + NUMBER = 2, +} + +/** + * Describes the enum sebuf.http.EnumEncoding. + */ +export const EnumEncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 2); + +/** + * EmptyBehavior controls how empty message fields serialize to JSON. + * "Empty" means all fields at proto default (proto.Size() == 0). + * + * @generated from enum sebuf.http.EmptyBehavior + */ +export enum EmptyBehavior { + /** + * Follow default behavior: serialize empty messages as {} (same as PRESERVE) + * + * @generated from enum value: EMPTY_BEHAVIOR_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Serialize empty messages as {} (explicit same as default) + * + * @generated from enum value: EMPTY_BEHAVIOR_PRESERVE = 1; + */ + PRESERVE = 1, + + /** + * Serialize empty messages as null + * + * @generated from enum value: EMPTY_BEHAVIOR_NULL = 2; + */ + NULL = 2, + + /** + * Omit the field entirely when message is empty + * + * @generated from enum value: EMPTY_BEHAVIOR_OMIT = 3; + */ + OMIT = 3, +} + +/** + * Describes the enum sebuf.http.EmptyBehavior. + */ +export const EmptyBehaviorSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 3); + +/** + * TimestampFormat controls how google.protobuf.Timestamp fields serialize to JSON. + * + * @generated from enum sebuf.http.TimestampFormat + */ +export enum TimestampFormat { + /** + * Follow protojson default (RFC 3339 string: "2024-01-15T09:30:00Z") + * + * @generated from enum value: TIMESTAMP_FORMAT_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Explicit RFC 3339 (same as default but self-documenting) + * + * @generated from enum value: TIMESTAMP_FORMAT_RFC3339 = 1; + */ + RFC3339 = 1, + + /** + * Unix seconds as integer: 1705312200 (nanos truncated) + * + * @generated from enum value: TIMESTAMP_FORMAT_UNIX_SECONDS = 2; + */ + UNIX_SECONDS = 2, + + /** + * Unix milliseconds as integer: 1705312200000 (sub-millisecond nanos truncated) + * + * @generated from enum value: TIMESTAMP_FORMAT_UNIX_MILLIS = 3; + */ + UNIX_MILLIS = 3, + + /** + * Date-only string: "2024-01-15" (time component dropped, lossy) + * + * @generated from enum value: TIMESTAMP_FORMAT_DATE = 4; + */ + DATE = 4, +} + +/** + * Describes the enum sebuf.http.TimestampFormat. + */ +export const TimestampFormatSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 4); + +/** + * BytesEncoding controls how bytes fields serialize to JSON. + * + * @generated from enum sebuf.http.BytesEncoding + */ +export enum BytesEncoding { + /** + * Follow protojson default (standard base64 with padding) + * + * @generated from enum value: BYTES_ENCODING_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Standard base64 with padding (RFC 4648): "SGVsbG8=" + * + * @generated from enum value: BYTES_ENCODING_BASE64 = 1; + */ + BASE64 = 1, + + /** + * Base64 without padding: "SGVsbG8" + * + * @generated from enum value: BYTES_ENCODING_BASE64_RAW = 2; + */ + BASE64_RAW = 2, + + /** + * URL-safe base64 with padding: "SGVsbG8=" + * + * @generated from enum value: BYTES_ENCODING_BASE64URL = 3; + */ + BASE64URL = 3, + + /** + * URL-safe base64 without padding: "SGVsbG8" + * + * @generated from enum value: BYTES_ENCODING_BASE64URL_RAW = 4; + */ + BASE64URL_RAW = 4, + + /** + * Hexadecimal encoding (lowercase): "48656c6c6f" + * + * @generated from enum value: BYTES_ENCODING_HEX = 5; + */ + HEX = 5, +} + +/** + * Describes the enum sebuf.http.BytesEncoding. + */ +export const BytesEncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 5); + +/** + * @generated from extension: sebuf.http.HttpConfig config = 50003; + */ +export const config: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 0); + +/** + * @generated from extension: sebuf.http.ServiceConfig service_config = 50004; + */ +export const service_config: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 1); + +/** + * Controls oneof serialization as a discriminated union. + * When set, adds a discriminator field to the JSON output identifying which variant is set. + * + * @generated from extension: optional sebuf.http.OneofConfig oneof_config = 50017; + */ +export const oneof_config: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 2); + +/** + * Example values for documentation/OpenAPI + * + * @generated from extension: sebuf.http.FieldExamples field_examples = 50007; + */ +export const field_examples: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 3); + +/** + * Query parameter configuration for a field + * + * @generated from extension: sebuf.http.QueryConfig query = 50008; + */ +export const query: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 4); + +/** + * Mark a repeated field for unwrapping when parent message is a map value. + * When set to true on a repeated field, and the message containing this field + * is used as a map value, the JSON serialization will collapse the wrapper + * to just the unwrapped field's array value. + * Constraints: Only valid on repeated fields, only one per message. + * + * @generated from extension: bool unwrap = 50009; + */ +export const unwrap: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 5); + +/** + * Controls int64/uint64 JSON encoding for this field. + * Valid on: int64, sint64, sfixed64, uint64, fixed64 fields. + * Default: STRING encoding (protojson default for JavaScript precision safety). + * + * @generated from extension: optional sebuf.http.Int64Encoding int64_encoding = 50010; + */ +export const int64_encoding: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 6); + +/** + * Controls enum JSON encoding for this field. + * Valid on: enum fields only. + * Default: STRING encoding (protojson default using proto enum names). + * + * @generated from extension: optional sebuf.http.EnumEncoding enum_encoding = 50011; + */ +export const enum_encoding: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 7); + +/** + * Mark a primitive field as nullable (explicit null vs absent). + * Only valid on proto3 optional fields (HasOptionalKeyword=true). + * When true: unset field serializes as null, set field serializes normally. + * When false (default): unset field is omitted from JSON. + * + * @generated from extension: optional bool nullable = 50013; + */ +export const nullable: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 8); + +/** + * Controls how empty message fields serialize to JSON. + * Only valid on singular message fields (not repeated, not map). + * "Empty" = all fields at proto default (proto.Size() == 0). + * + * @generated from extension: optional sebuf.http.EmptyBehavior empty_behavior = 50014; + */ +export const empty_behavior: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 9); + +/** + * Controls timestamp JSON encoding for this field. + * Valid on: google.protobuf.Timestamp fields only. + * Default: RFC3339 (protojson default). + * + * @generated from extension: optional sebuf.http.TimestampFormat timestamp_format = 50015; + */ +export const timestamp_format: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 10); + +/** + * Controls bytes JSON encoding for this field. + * Valid on: bytes fields only. + * Default: BASE64 (protojson default). + * + * @generated from extension: optional sebuf.http.BytesEncoding bytes_encoding = 50016; + */ +export const bytes_encoding: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 11); + +/** + * Custom discriminator value for this oneof variant field. + * When set, this value is used in the discriminator field instead of the proto field name. + * Only valid on fields that are part of a oneof with oneof_config annotation. + * + * @generated from extension: optional string oneof_value = 50018; + */ +export const oneof_value: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 12); + +/** + * Flatten a nested message field, promoting its child fields to the parent level in JSON. + * Only valid on singular message fields (not repeated, not map, not oneof variant). + * When true: child message fields appear at the parent level (e.g., address.street becomes street). + * + * @generated from extension: optional bool flatten = 50019; + */ +export const flatten: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 13); + +/** + * Prefix to prepend to flattened field names to avoid collisions. + * Only valid when flatten=true is also set. + * Example: flatten_prefix="billing_" with child field "street" produces "billing_street" in JSON. + * + * @generated from extension: optional string flatten_prefix = 50020; + */ +export const flatten_prefix: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 14); + +/** + * Custom JSON string for this enum value (e.g., "active" instead of "STATUS_ACTIVE"). + * When set, this value is used for JSON serialization instead of the proto name. + * Combines with enum_encoding=STRING on fields using this enum. + * + * @generated from extension: optional string enum_value = 50012; + */ +export const enum_value: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 15); + diff --git a/internal/tscommon/typecheck/typecheck.go b/internal/tscommon/typecheck/typecheck.go new file mode 100644 index 00000000..c153a384 --- /dev/null +++ b/internal/tscommon/typecheck/typecheck.go @@ -0,0 +1,81 @@ +// Package typecheck runs the TypeScript compiler over generated output in +// tests, verifying that the emitted module tree typechecks under the strict +// nodenext settings the modules layout targets. Byte-comparing golden files +// catches regressions in what we emit; this catches emitting something that +// was never valid TypeScript in the first place (duplicate identifiers, +// shadowed globals, unused imports, wrong casts). +package typecheck + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// tsVersion pins the compiler fetched by the npx fallback so results don't +// drift with whatever "latest" is on the machine running the tests. +const tsVersion = "5.9.3" + +// Dir typechecks every .ts file under dir with tsc --noEmit. The test is +// skipped when no TypeScript toolchain is available (neither tsc nor npx on +// PATH); any compile error fails the test with the compiler output. +func Dir(t *testing.T, dir string) { + t.Helper() + + tsc := tscCommand(t) + + absDir, err := filepath.Abs(dir) + if err != nil { + t.Fatalf("failed to resolve %s: %v", dir, err) + } + + // noUnusedLocals is deliberate: a generated module importing a symbol it + // never uses is a generator bug (and breaks consumers with strict configs). + config := fmt.Sprintf(`{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "es2020", + "lib": ["es2020", "dom", "dom.iterable", "dom.asynciterable"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "noUnusedLocals": true + }, + "include": [%q] +} +`, filepath.ToSlash(absDir)+"/**/*.ts") + + tsconfigPath := filepath.Join(t.TempDir(), "tsconfig.json") + if writeErr := os.WriteFile(tsconfigPath, []byte(config), 0o600); writeErr != nil { + t.Fatalf("failed to write tsconfig: %v", writeErr) + } + + args := make([]string, 0, len(tsc)+1) + args = append(args, tsc[1:]...) + args = append(args, "-p", tsconfigPath) + //nolint:gosec // test-only helper invoking the compiler found on PATH + cmd := exec.CommandContext(context.Background(), tsc[0], args...) + out, runErr := cmd.CombinedOutput() + if runErr != nil { + t.Errorf("tsc --noEmit failed for %s: %v\n%s", dir, runErr, out) + } +} + +// tscCommand returns the command (argv prefix) that invokes the TypeScript +// compiler: tsc from PATH when installed, otherwise a pinned compiler via +// npx. Skips the test when neither is available. +func tscCommand(t *testing.T) []string { + t.Helper() + if path, err := exec.LookPath("tsc"); err == nil { + return []string{path} + } + if npx, err := exec.LookPath("npx"); err == nil { + return []string{npx, "--yes", "--package=typescript@" + tsVersion, "tsc"} + } + t.Skip("neither tsc nor npx found on PATH, skipping TypeScript typecheck") + return nil +} diff --git a/internal/tsservergen/es_typecheck_test.go b/internal/tsservergen/es_typecheck_test.go new file mode 100644 index 00000000..20e1e483 --- /dev/null +++ b/internal/tsservergen/es_typecheck_test.go @@ -0,0 +1,55 @@ +package tsservergen + +import ( + "os" + "path/filepath" + "testing" + + "github.com/SebastienMelki/sebuf/internal/tscommon/typecheck" +) + +// TestTSServerGenESGoldenTypecheck runs tsc --noEmit over the checked-in es +// goldens (testdata/golden/es). Byte-comparing goldens locks down what sebuf +// emits; this locks down that it compiles against the real @bufbuild/protobuf +// types and the symbols protoc-gen-es actually exported — the machine check +// that ESQualifiedName still matches protoc-gen-es naming. Skips (never fails) +// when tsc/npx or a @bufbuild/protobuf install is unavailable. +func TestTSServerGenESGoldenTypecheck(t *testing.T) { + goldenES := filepath.Join("testdata", "golden", "es") + linkESNodeModules(t, goldenES) + typecheck.Dir(t, goldenES) +} + +// linkESNodeModules symlinks a node_modules that resolves @bufbuild/protobuf +// into dir so tsc (nodenext resolution) can resolve the runtime imports. It +// mirrors the discovery in tsclientgen's conformance_test.go: +// SEBUF_ES_NODE_MODULES overrides, else the git-ignored .scratch/es-spike +// install; skips when neither exists. The symlink is created only if absent and +// removed on cleanup, leaving a developer's own symlink untouched. +func linkESNodeModules(t *testing.T, dir string) { + t.Helper() + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + nodeModules := os.Getenv("SEBUF_ES_NODE_MODULES") + if nodeModules == "" { + nodeModules = filepath.Join(baseDir, "..", "..", ".scratch", "es-spike", "node_modules") + } + if _, statErr := os.Stat(filepath.Join(nodeModules, "@bufbuild", "protobuf", "package.json")); statErr != nil { + t.Skipf("@bufbuild/protobuf not found under %s, skipping es typecheck", nodeModules) + } + absNodeModules, err := filepath.Abs(nodeModules) + if err != nil { + t.Fatalf("Failed to resolve node_modules path: %v", err) + } + + linkPath := filepath.Join(dir, "node_modules") + if _, linkErr := os.Lstat(linkPath); os.IsNotExist(linkErr) { + if symErr := os.Symlink(absNodeModules, linkPath); symErr != nil { + t.Fatalf("Failed to symlink node_modules for es typecheck: %v", symErr) + } + t.Cleanup(func() { _ = os.Remove(linkPath) }) + } +} diff --git a/internal/tsservergen/testdata/golden/es/sebuf/http/annotations_pb.ts b/internal/tsservergen/testdata/golden/es/sebuf/http/annotations_pb.ts new file mode 100644 index 00000000..7bc2fbd7 --- /dev/null +++ b/internal/tsservergen/testdata/golden/es/sebuf/http/annotations_pb.ts @@ -0,0 +1,564 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts,import_extension=js" +// @generated from file sebuf/http/annotations.proto (package sebuf.http, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenExtension, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, extDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { EnumValueOptions, FieldOptions, MethodOptions, OneofOptions, ServiceOptions } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_descriptor } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file sebuf/http/annotations.proto. + */ +export const file_sebuf_http_annotations: GenFile = /*@__PURE__*/ + fileDesc("ChxzZWJ1Zi9odHRwL2Fubm90YXRpb25zLnByb3RvEgpzZWJ1Zi5odHRwIlIKCkh0dHBDb25maWcSDAoEcGF0aBgBIAEoCRImCgZtZXRob2QYAiABKA4yFi5zZWJ1Zi5odHRwLkh0dHBNZXRob2QSDgoGc3RyZWFtGAMgASgIIiIKDVNlcnZpY2VDb25maWcSEQoJYmFzZV9wYXRoGAEgASgJIh8KDUZpZWxkRXhhbXBsZXMSDgoGdmFsdWVzGAEgAygJIi0KC1F1ZXJ5Q29uZmlnEgwKBG5hbWUYASABKAkSEAoIcmVxdWlyZWQYAiABKAgiNQoLT25lb2ZDb25maWcSFQoNZGlzY3JpbWluYXRvchgBIAEoCRIPCgdmbGF0dGVuGAIgASgIKpgBCgpIdHRwTWV0aG9kEhsKF0hUVFBfTUVUSE9EX1VOU1BFQ0lGSUVEEAASEwoPSFRUUF9NRVRIT0RfR0VUEAESFAoQSFRUUF9NRVRIT0RfUE9TVBACEhMKD0hUVFBfTUVUSE9EX1BVVBADEhYKEkhUVFBfTUVUSE9EX0RFTEVURRAEEhUKEUhUVFBfTUVUSE9EX1BBVENIEAUqZQoNSW50NjRFbmNvZGluZxIeChpJTlQ2NF9FTkNPRElOR19VTlNQRUNJRklFRBAAEhkKFUlOVDY0X0VOQ09ESU5HX1NUUklORxABEhkKFUlOVDY0X0VOQ09ESU5HX05VTUJFUhACKmEKDEVudW1FbmNvZGluZxIdChlFTlVNX0VOQ09ESU5HX1VOU1BFQ0lGSUVEEAASGAoURU5VTV9FTkNPRElOR19TVFJJTkcQARIYChRFTlVNX0VOQ09ESU5HX05VTUJFUhACKn4KDUVtcHR5QmVoYXZpb3ISHgoaRU1QVFlfQkVIQVZJT1JfVU5TUEVDSUZJRUQQABIbChdFTVBUWV9CRUhBVklPUl9QUkVTRVJWRRABEhcKE0VNUFRZX0JFSEFWSU9SX05VTEwQAhIXChNFTVBUWV9CRUhBVklPUl9PTUlUEAMqsQEKD1RpbWVzdGFtcEZvcm1hdBIgChxUSU1FU1RBTVBfRk9STUFUX1VOU1BFQ0lGSUVEEAASHAoYVElNRVNUQU1QX0ZPUk1BVF9SRkMzMzM5EAESIQodVElNRVNUQU1QX0ZPUk1BVF9VTklYX1NFQ09ORFMQAhIgChxUSU1FU1RBTVBfRk9STUFUX1VOSVhfTUlMTElTEAMSGQoVVElNRVNUQU1QX0ZPUk1BVF9EQVRFEAQqwQEKDUJ5dGVzRW5jb2RpbmcSHgoaQllURVNfRU5DT0RJTkdfVU5TUEVDSUZJRUQQABIZChVCWVRFU19FTkNPRElOR19CQVNFNjQQARIdChlCWVRFU19FTkNPRElOR19CQVNFNjRfUkFXEAISHAoYQllURVNfRU5DT0RJTkdfQkFTRTY0VVJMEAMSIAocQllURVNfRU5DT0RJTkdfQkFTRTY0VVJMX1JBVxAEEhYKEkJZVEVTX0VOQ09ESU5HX0hFWBAFOlAKBmNvbmZpZxIeLmdvb2dsZS5wcm90b2J1Zi5NZXRob2RPcHRpb25zGNOGAyABKAsyFi5zZWJ1Zi5odHRwLkh0dHBDb25maWdSBmNvbmZpZzpjCg5zZXJ2aWNlX2NvbmZpZxIfLmdvb2dsZS5wcm90b2J1Zi5TZXJ2aWNlT3B0aW9ucxjUhgMgASgLMhkuc2VidWYuaHR0cC5TZXJ2aWNlQ29uZmlnUg1zZXJ2aWNlQ29uZmlnOl4KDG9uZW9mX2NvbmZpZxIdLmdvb2dsZS5wcm90b2J1Zi5PbmVvZk9wdGlvbnMY4YYDIAEoCzIXLnNlYnVmLmh0dHAuT25lb2ZDb25maWdSC29uZW9mQ29uZmlniAEBOmEKDmZpZWxkX2V4YW1wbGVzEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjXhgMgASgLMhkuc2VidWYuaHR0cC5GaWVsZEV4YW1wbGVzUg1maWVsZEV4YW1wbGVzOk4KBXF1ZXJ5Eh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjYhgMgASgLMhcuc2VidWYuaHR0cC5RdWVyeUNvbmZpZ1IFcXVlcnk6NwoGdW53cmFwEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjZhgMgASgIUgZ1bndyYXA6ZAoOaW50NjRfZW5jb2RpbmcSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGNqGAyABKA4yGS5zZWJ1Zi5odHRwLkludDY0RW5jb2RpbmdSDWludDY0RW5jb2RpbmeIAQE6YQoNZW51bV9lbmNvZGluZxIdLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMY24YDIAEoDjIYLnNlYnVmLmh0dHAuRW51bUVuY29kaW5nUgxlbnVtRW5jb2RpbmeIAQE6PgoIbnVsbGFibGUSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGN2GAyABKAhSCG51bGxhYmxliAEBOmQKDmVtcHR5X2JlaGF2aW9yEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjehgMgASgOMhkuc2VidWYuaHR0cC5FbXB0eUJlaGF2aW9yUg1lbXB0eUJlaGF2aW9yiAEBOmoKEHRpbWVzdGFtcF9mb3JtYXQSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGN+GAyABKA4yGy5zZWJ1Zi5odHRwLlRpbWVzdGFtcEZvcm1hdFIPdGltZXN0YW1wRm9ybWF0iAEBOmQKDmJ5dGVzX2VuY29kaW5nEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjghgMgASgOMhkuc2VidWYuaHR0cC5CeXRlc0VuY29kaW5nUg1ieXRlc0VuY29kaW5niAEBOkMKC29uZW9mX3ZhbHVlEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjihgMgASgJUgpvbmVvZlZhbHVliAEBOjwKB2ZsYXR0ZW4SHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGOOGAyABKAhSB2ZsYXR0ZW6IAQE6SQoOZmxhdHRlbl9wcmVmaXgSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGOSGAyABKAlSDWZsYXR0ZW5QcmVmaXiIAQE6RQoKZW51bV92YWx1ZRIhLmdvb2dsZS5wcm90b2J1Zi5FbnVtVmFsdWVPcHRpb25zGNyGAyABKAlSCWVudW1WYWx1ZYgBAUIrWilnaXRodWIuY29tL1NlYmFzdGllbk1lbGtpL3NlYnVmL2h0dHA7aHR0cGIGcHJvdG8z", [file_google_protobuf_descriptor]); + +/** + * HttpConfig defines HTTP-specific configuration for an RPC method + * + * @generated from message sebuf.http.HttpConfig + */ +export type HttpConfig = Message<"sebuf.http.HttpConfig"> & { + /** + * The HTTP path for this method (supports path variables like /users/{id}) + * + * @generated from field: string path = 1; + */ + path: string; + + /** + * The HTTP method (GET, POST, PUT, DELETE, PATCH). Defaults to POST if unspecified. + * + * @generated from field: sebuf.http.HttpMethod method = 2; + */ + method: HttpMethod; + + /** + * When true, this method uses Server-Sent Events (SSE) for streaming responses. + * The server sends events with Content-Type: text/event-stream. + * Each event is the response message serialized as JSON in the SSE data field. + * + * @generated from field: bool stream = 3; + */ + stream: boolean; +}; + +/** + * Describes the message sebuf.http.HttpConfig. + * Use `create(HttpConfigSchema)` to create a new message. + */ +export const HttpConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 0); + +/** + * ServiceConfig defines HTTP-specific configuration for an entire service + * + * @generated from message sebuf.http.ServiceConfig + */ +export type ServiceConfig = Message<"sebuf.http.ServiceConfig"> & { + /** + * Base path prefix for all methods in this service + * + * @generated from field: string base_path = 1; + */ + basePath: string; +}; + +/** + * Describes the message sebuf.http.ServiceConfig. + * Use `create(ServiceConfigSchema)` to create a new message. + */ +export const ServiceConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 1); + +/** + * FieldExamples defines example values for a field + * + * @generated from message sebuf.http.FieldExamples + */ +export type FieldExamples = Message<"sebuf.http.FieldExamples"> & { + /** + * List of example values for this field + * + * @generated from field: repeated string values = 1; + */ + values: string[]; +}; + +/** + * Describes the message sebuf.http.FieldExamples. + * Use `create(FieldExamplesSchema)` to create a new message. + */ +export const FieldExamplesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 2); + +/** + * QueryConfig defines query parameter configuration for a message field + * + * @generated from message sebuf.http.QueryConfig + */ +export type QueryConfig = Message<"sebuf.http.QueryConfig"> & { + /** + * The query parameter name in the URL (e.g., "page_size" for ?page_size=10) + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Whether this query parameter is required + * + * @generated from field: bool required = 2; + */ + required: boolean; +}; + +/** + * Describes the message sebuf.http.QueryConfig. + * Use `create(QueryConfigSchema)` to create a new message. + */ +export const QueryConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 3); + +/** + * OneofConfig controls oneof serialization as a discriminated union. + * Applied to a oneof definition via (sebuf.http.oneof_config). + * + * @generated from message sebuf.http.OneofConfig + */ +export type OneofConfig = Message<"sebuf.http.OneofConfig"> & { + /** + * The JSON field name for the discriminator (e.g., "type", "kind"). + * Required: if empty, the annotation is ignored. + * + * @generated from field: string discriminator = 1; + */ + discriminator: string; + + /** + * Whether to flatten variant message fields to the same level as the discriminator. + * When true: variant's child fields are promoted to the parent object alongside the discriminator. + * When false: variant stays nested under its field name with the discriminator alongside. + * Requires all variant fields to be message types when true. + * + * @generated from field: bool flatten = 2; + */ + flatten: boolean; +}; + +/** + * Describes the message sebuf.http.OneofConfig. + * Use `create(OneofConfigSchema)` to create a new message. + */ +export const OneofConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sebuf_http_annotations, 4); + +/** + * HttpMethod specifies the HTTP verb for an RPC method + * + * @generated from enum sebuf.http.HttpMethod + */ +export enum HttpMethod { + /** + * Unspecified defaults to POST for backward compatibility + * + * @generated from enum value: HTTP_METHOD_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: HTTP_METHOD_GET = 1; + */ + GET = 1, + + /** + * @generated from enum value: HTTP_METHOD_POST = 2; + */ + POST = 2, + + /** + * @generated from enum value: HTTP_METHOD_PUT = 3; + */ + PUT = 3, + + /** + * @generated from enum value: HTTP_METHOD_DELETE = 4; + */ + DELETE = 4, + + /** + * @generated from enum value: HTTP_METHOD_PATCH = 5; + */ + PATCH = 5, +} + +/** + * Describes the enum sebuf.http.HttpMethod. + */ +export const HttpMethodSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 0); + +/** + * Int64Encoding controls how int64/uint64 fields serialize to JSON + * + * @generated from enum sebuf.http.Int64Encoding + */ +export enum Int64Encoding { + /** + * Follow protojson default (serialize as string) + * + * @generated from enum value: INT64_ENCODING_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Explicit string encoding: "12345" + * + * @generated from enum value: INT64_ENCODING_STRING = 1; + */ + STRING = 1, + + /** + * Numeric encoding: 12345 (precision warning for values > 2^53) + * + * @generated from enum value: INT64_ENCODING_NUMBER = 2; + */ + NUMBER = 2, +} + +/** + * Describes the enum sebuf.http.Int64Encoding. + */ +export const Int64EncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 1); + +/** + * EnumEncoding controls how enum fields serialize to JSON + * + * @generated from enum sebuf.http.EnumEncoding + */ +export enum EnumEncoding { + /** + * Follow protojson default (serialize as proto name string) + * + * @generated from enum value: ENUM_ENCODING_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Explicit string encoding: "STATUS_ACTIVE" + * + * @generated from enum value: ENUM_ENCODING_STRING = 1; + */ + STRING = 1, + + /** + * Numeric encoding: 1 + * + * @generated from enum value: ENUM_ENCODING_NUMBER = 2; + */ + NUMBER = 2, +} + +/** + * Describes the enum sebuf.http.EnumEncoding. + */ +export const EnumEncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 2); + +/** + * EmptyBehavior controls how empty message fields serialize to JSON. + * "Empty" means all fields at proto default (proto.Size() == 0). + * + * @generated from enum sebuf.http.EmptyBehavior + */ +export enum EmptyBehavior { + /** + * Follow default behavior: serialize empty messages as {} (same as PRESERVE) + * + * @generated from enum value: EMPTY_BEHAVIOR_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Serialize empty messages as {} (explicit same as default) + * + * @generated from enum value: EMPTY_BEHAVIOR_PRESERVE = 1; + */ + PRESERVE = 1, + + /** + * Serialize empty messages as null + * + * @generated from enum value: EMPTY_BEHAVIOR_NULL = 2; + */ + NULL = 2, + + /** + * Omit the field entirely when message is empty + * + * @generated from enum value: EMPTY_BEHAVIOR_OMIT = 3; + */ + OMIT = 3, +} + +/** + * Describes the enum sebuf.http.EmptyBehavior. + */ +export const EmptyBehaviorSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 3); + +/** + * TimestampFormat controls how google.protobuf.Timestamp fields serialize to JSON. + * + * @generated from enum sebuf.http.TimestampFormat + */ +export enum TimestampFormat { + /** + * Follow protojson default (RFC 3339 string: "2024-01-15T09:30:00Z") + * + * @generated from enum value: TIMESTAMP_FORMAT_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Explicit RFC 3339 (same as default but self-documenting) + * + * @generated from enum value: TIMESTAMP_FORMAT_RFC3339 = 1; + */ + RFC3339 = 1, + + /** + * Unix seconds as integer: 1705312200 (nanos truncated) + * + * @generated from enum value: TIMESTAMP_FORMAT_UNIX_SECONDS = 2; + */ + UNIX_SECONDS = 2, + + /** + * Unix milliseconds as integer: 1705312200000 (sub-millisecond nanos truncated) + * + * @generated from enum value: TIMESTAMP_FORMAT_UNIX_MILLIS = 3; + */ + UNIX_MILLIS = 3, + + /** + * Date-only string: "2024-01-15" (time component dropped, lossy) + * + * @generated from enum value: TIMESTAMP_FORMAT_DATE = 4; + */ + DATE = 4, +} + +/** + * Describes the enum sebuf.http.TimestampFormat. + */ +export const TimestampFormatSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 4); + +/** + * BytesEncoding controls how bytes fields serialize to JSON. + * + * @generated from enum sebuf.http.BytesEncoding + */ +export enum BytesEncoding { + /** + * Follow protojson default (standard base64 with padding) + * + * @generated from enum value: BYTES_ENCODING_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Standard base64 with padding (RFC 4648): "SGVsbG8=" + * + * @generated from enum value: BYTES_ENCODING_BASE64 = 1; + */ + BASE64 = 1, + + /** + * Base64 without padding: "SGVsbG8" + * + * @generated from enum value: BYTES_ENCODING_BASE64_RAW = 2; + */ + BASE64_RAW = 2, + + /** + * URL-safe base64 with padding: "SGVsbG8=" + * + * @generated from enum value: BYTES_ENCODING_BASE64URL = 3; + */ + BASE64URL = 3, + + /** + * URL-safe base64 without padding: "SGVsbG8" + * + * @generated from enum value: BYTES_ENCODING_BASE64URL_RAW = 4; + */ + BASE64URL_RAW = 4, + + /** + * Hexadecimal encoding (lowercase): "48656c6c6f" + * + * @generated from enum value: BYTES_ENCODING_HEX = 5; + */ + HEX = 5, +} + +/** + * Describes the enum sebuf.http.BytesEncoding. + */ +export const BytesEncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_sebuf_http_annotations, 5); + +/** + * @generated from extension: sebuf.http.HttpConfig config = 50003; + */ +export const config: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 0); + +/** + * @generated from extension: sebuf.http.ServiceConfig service_config = 50004; + */ +export const service_config: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 1); + +/** + * Controls oneof serialization as a discriminated union. + * When set, adds a discriminator field to the JSON output identifying which variant is set. + * + * @generated from extension: optional sebuf.http.OneofConfig oneof_config = 50017; + */ +export const oneof_config: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 2); + +/** + * Example values for documentation/OpenAPI + * + * @generated from extension: sebuf.http.FieldExamples field_examples = 50007; + */ +export const field_examples: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 3); + +/** + * Query parameter configuration for a field + * + * @generated from extension: sebuf.http.QueryConfig query = 50008; + */ +export const query: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 4); + +/** + * Mark a repeated field for unwrapping when parent message is a map value. + * When set to true on a repeated field, and the message containing this field + * is used as a map value, the JSON serialization will collapse the wrapper + * to just the unwrapped field's array value. + * Constraints: Only valid on repeated fields, only one per message. + * + * @generated from extension: bool unwrap = 50009; + */ +export const unwrap: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 5); + +/** + * Controls int64/uint64 JSON encoding for this field. + * Valid on: int64, sint64, sfixed64, uint64, fixed64 fields. + * Default: STRING encoding (protojson default for JavaScript precision safety). + * + * @generated from extension: optional sebuf.http.Int64Encoding int64_encoding = 50010; + */ +export const int64_encoding: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 6); + +/** + * Controls enum JSON encoding for this field. + * Valid on: enum fields only. + * Default: STRING encoding (protojson default using proto enum names). + * + * @generated from extension: optional sebuf.http.EnumEncoding enum_encoding = 50011; + */ +export const enum_encoding: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 7); + +/** + * Mark a primitive field as nullable (explicit null vs absent). + * Only valid on proto3 optional fields (HasOptionalKeyword=true). + * When true: unset field serializes as null, set field serializes normally. + * When false (default): unset field is omitted from JSON. + * + * @generated from extension: optional bool nullable = 50013; + */ +export const nullable: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 8); + +/** + * Controls how empty message fields serialize to JSON. + * Only valid on singular message fields (not repeated, not map). + * "Empty" = all fields at proto default (proto.Size() == 0). + * + * @generated from extension: optional sebuf.http.EmptyBehavior empty_behavior = 50014; + */ +export const empty_behavior: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 9); + +/** + * Controls timestamp JSON encoding for this field. + * Valid on: google.protobuf.Timestamp fields only. + * Default: RFC3339 (protojson default). + * + * @generated from extension: optional sebuf.http.TimestampFormat timestamp_format = 50015; + */ +export const timestamp_format: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 10); + +/** + * Controls bytes JSON encoding for this field. + * Valid on: bytes fields only. + * Default: BASE64 (protojson default). + * + * @generated from extension: optional sebuf.http.BytesEncoding bytes_encoding = 50016; + */ +export const bytes_encoding: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 11); + +/** + * Custom discriminator value for this oneof variant field. + * When set, this value is used in the discriminator field instead of the proto field name. + * Only valid on fields that are part of a oneof with oneof_config annotation. + * + * @generated from extension: optional string oneof_value = 50018; + */ +export const oneof_value: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 12); + +/** + * Flatten a nested message field, promoting its child fields to the parent level in JSON. + * Only valid on singular message fields (not repeated, not map, not oneof variant). + * When true: child message fields appear at the parent level (e.g., address.street becomes street). + * + * @generated from extension: optional bool flatten = 50019; + */ +export const flatten: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 13); + +/** + * Prefix to prepend to flattened field names to avoid collisions. + * Only valid when flatten=true is also set. + * Example: flatten_prefix="billing_" with child field "street" produces "billing_street" in JSON. + * + * @generated from extension: optional string flatten_prefix = 50020; + */ +export const flatten_prefix: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 14); + +/** + * Custom JSON string for this enum value (e.g., "active" instead of "STATUS_ACTIVE"). + * When set, this value is used for JSON serialization instead of the proto name. + * Combines with enum_encoding=STRING on fields using this enum. + * + * @generated from extension: optional string enum_value = 50012; + */ +export const enum_value: GenExtension = /*@__PURE__*/ + extDesc(file_sebuf_http_annotations, 15); + From 5854efd44dd8fc2e04f4b0086b7872cf553d4eb2 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:43:53 +0300 Subject: [PATCH 13/15] docs(ts): document es-mode URL-param and init-shape limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL path/query parameters do not route through create/toJson/fromJson — only request bodies, response bodies, and SSE events do — and MessageInitShape makes every request field optional, so a missing path parameter compiles and surfaces only as a runtime "undefined" URL segment. Also notes the es golden typecheck tests as the guard for protoc-gen-es naming drift. Co-Authored-By: Claude Fable 5 --- docs/client-generation.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/client-generation.md b/docs/client-generation.md index 2a7119b6..dde95c70 100644 --- a/docs/client-generation.md +++ b/docs/client-generation.md @@ -769,7 +769,23 @@ ignored rather than causing an error). I/O do resolve — but this was verified against `@bufbuild/protoc-gen-es@2.12.1`; if you use a different protoc-gen-es version and hit a naming mismatch, promoting the message to top level is the safe - workaround. + workaround. The es golden typecheck tests (`es_typecheck_test.go` in both + generators) run `tsc --noEmit` over the goldens against the real + `@bufbuild/protobuf` package, so a naming divergence fails CI rather than a + consumer build. +- **URL parameters do not route through the protobuf-es codec.** Only request + bodies, response bodies, and SSE events go through + `create`/`toJson`/`fromJson`. Path and query parameter values are read + directly off the request init shape and string-coerced + (`encodeURIComponent(String(...))` / `URLSearchParams`), the same as + hand-rolled mode. URL parameters have no protojson canonical form, so this is + by design — but it means codec guarantees (e.g. int64 normalization) do not + apply to them. +- **Path parameters are not compile-time required.** `MessageInitShape<...>` + makes every request field optional at the type level, so omitting a path + parameter field compiles and produces a URL containing the string + `"undefined"` at runtime. Hand-rolled mode's request interfaces mark such + fields required; in es-mode this check is deferred to the server's 4xx. ## See Also From 7c84726b2851bdc7ec5b635c4bd399cee8424ea9 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:07:34 +0300 Subject: [PATCH 14/15] feat(ts): fail loud on es-mode JSON-mapping annotations + fix URL param coercion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit es-mode serializes with protobuf-es's canonical protojson codec (toJson/fromJson) and applies none of sebuf's sebuf.http JSON-mapping annotations. A sebuf Go server layers an annotation-aware transform (MarshalJSONSebuf) on top of protojson that deliberately diverges from canonical protojson, so for any annotated proto es-mode is on a different, incompatible wire than the Go server (and the hand-rolled TS output). Rather than emit a silently-incompatible second wire format, fail loud. - Add tscommon.CheckESMessageAnnotations: walks each RPC's transitive request/response/SSE-event message closure and errors at generation time on unwrap, oneof_config flatten, flatten/flatten_prefix, enum_value, timestamp_format, bytes_encoding, nullable, empty_behavior, enum_encoding=NUMBER, and int64_encoding=NUMBER. Wired into both the ts-client and ts-server generators alongside the existing enum-param guard. Regression tests cover all ten annotation fixtures in both generators. - Fix non-string path params in es-mode (server): coerce the raw URL string to the protobuf-es field type at both emission sites (create() init shape and branded-body merge) via esPathParamInitExpr — Number() for 32-bit/float, BigInt() for 64-bit, === "true" for bool, strings verbatim. Previously these emitted uncompilable code (TS2322). Extend the get_params fixture with numeric/bool path params (GET) and a numeric path param merged into a POST body, so the es golden tsc --noEmit typecheck compiles the coercion. - Fix repeated non-string query params (client): wrap the element in String(v) so URLSearchParams.append typechecks for repeated int32/bool/etc. - Docs: replace the implied Go-server-parity framing with an explicit wire-contract note (es-mode speaks canonical protojson, honors no JSON-mapping annotation, only wire-compatible for protos using none, enforced by the guard); add it as the headline Known limitation; correct the URL-param note to describe the new path-param coercion. Co-Authored-By: Claude Opus 4.8 --- docs/client-generation.md | 51 ++++++-- internal/tsclientgen/generator.go | 21 ++- internal/tsclientgen/inprocess_golden_test.go | 52 ++++++++ .../testdata/golden/es/get_params_client.ts | 55 +++++++- .../testdata/golden/es/get_params_pb.ts | 90 ++++++++++++- .../testdata/golden/query_params_client.ts | 8 +- .../testdata/proto/get_params.proto | 42 +++++- internal/tscommon/es_guard.go | 123 ++++++++++++++++++ internal/tsservergen/generator.go | 52 +++++++- internal/tsservergen/inprocess_golden_test.go | 52 ++++++++ .../testdata/golden/es/get_params_pb.ts | 90 ++++++++++++- .../testdata/golden/es/get_params_server.ts | 104 ++++++++++++++- .../testdata/proto/get_params.proto | 42 +++++- 13 files changed, 740 insertions(+), 42 deletions(-) create mode 100644 internal/tscommon/es_guard.go diff --git a/docs/client-generation.md b/docs/client-generation.md index dde95c70..2d2718f4 100644 --- a/docs/client-generation.md +++ b/docs/client-generation.md @@ -643,10 +643,28 @@ per-proto type module `.ts` described above). Passing **protobuf-es transport mode**: instead of declaring their own interfaces, they consume the message types and schemas emitted by [`protoc-gen-es`](https://github.com/bufbuild/protobuf-es) (the `_pb.ts` -files) and serialize on the wire through protobuf-es's canonical -`fromJson`/`toJson`. This gives you protobuf-es's fully spec-compliant proto3 -JSON encoding (defaults, `bigint`, oneofs, well-known types) for free, shared -across your whole app. +files) and serialize on the wire through protobuf-es's `fromJson`/`toJson`. This +gives you protobuf-es's spec-compliant **canonical proto3 JSON** encoding +(defaults, `bigint`, oneofs, well-known types) for free, shared across your whole +app. + +> **Wire contract — read this before enabling es-mode.** es-mode speaks +> **canonical protojson** and applies **none** of sebuf's `sebuf.http` +> JSON-mapping annotations (`unwrap`, `oneof_config` flatten, `flatten` / +> `flatten_prefix`, `enum_value`, `timestamp_format`, `bytes_encoding`, +> `nullable`, `empty_behavior`, `enum_encoding`, `int64_encoding`). A sebuf Go +> server, by contrast, layers an annotation-aware transform +> (`MarshalJSONSebuf`) on top of protojson that deliberately diverges from +> canonical protojson wherever one of those annotations is set. es-mode does +> **not** yet have the TypeScript equivalent of that transform layer, so for any +> annotated proto it is on a **different, incompatible wire** than the sebuf Go +> server (and than the default hand-rolled TS output). It is therefore only +> wire-compatible with a sebuf server for protos that use **none** of those +> annotations. This is not left to chance: the generator **walks each RPC's +> request/response message closure and fails loud at generation time** if it +> finds any such annotation (see `CheckESMessageAnnotations` in +> `internal/tscommon/es_guard.go`). Use the default hand-rolled runtime for +> services that rely on JSON-mapping annotations. ### buf.gen.yaml shape @@ -749,6 +767,16 @@ ignored rather than causing an error). ### Known limitations +- **`sebuf.http` JSON-mapping annotations are not honored (fail-loud).** As + described in the wire-contract note above, es-mode emits canonical protojson + and does not apply any JSON-mapping annotation. The generator rejects, at + generation time, any RPC whose request/response message closure carries one — + with an error such as `ts_runtime=protobuf-es: field User.status uses the + enum_value JSON-mapping annotation (reachable from the response of + UserService.GetUser), which es-mode cannot honor`. Porting the sebuf transform + layer to TypeScript (so annotated protos round-trip byte-for-byte with the Go + server) is tracked as future work; until then, use hand-rolled mode for those + services. - **Enum path AND query parameters are not yet supported.** protobuf-es enums are numeric, but path- and query-parameter values arrive as strings on the wire; safely bridging the two requires a name↔number conversion that is not @@ -775,12 +803,15 @@ ignored rather than causing an error). consumer build. - **URL parameters do not route through the protobuf-es codec.** Only request bodies, response bodies, and SSE events go through - `create`/`toJson`/`fromJson`. Path and query parameter values are read - directly off the request init shape and string-coerced - (`encodeURIComponent(String(...))` / `URLSearchParams`), the same as - hand-rolled mode. URL parameters have no protojson canonical form, so this is - by design — but it means codec guarantees (e.g. int64 normalization) do not - apply to them. + `create`/`toJson`/`fromJson`. Path and query parameter values are derived + directly from strings: on the client they are string-coerced into the URL + (`encodeURIComponent(String(...))` / `URLSearchParams`); on the server they are + read off the URL and coerced back to the field's protobuf-es type before being + placed on the request message (numeric fields via `Number(...)`, 64-bit via + `BigInt(...)`, booleans via `=== "true"`, strings verbatim). URL parameters + have no protojson canonical form, so this coercion — rather than the codec — is + by design; it means codec guarantees (e.g. int64 string/number normalization) + do not apply to them. - **Path parameters are not compile-time required.** `MessageInitShape<...>` makes every request field optional at the type level, so omitting a path parameter field compiles and produces a URL containing the string diff --git a/internal/tsclientgen/generator.go b/internal/tsclientgen/generator.go index 3bfef799..361a6ccb 100644 --- a/internal/tsclientgen/generator.go +++ b/internal/tsclientgen/generator.go @@ -37,12 +37,27 @@ func (g *Generator) generateServiceClient(p printer, service *protogen.Service) // Enum path/query parameters are not representable in protobuf-es mode // (numeric enums vs string URL params); fail loud rather than emit code that - // fails downstream tsc. + // fails downstream tsc. Body/response messages carrying JSON-mapping + // annotations es-mode cannot honor are rejected the same way. if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { for _, method := range service.Methods { if err := checkNoEnumParamsES(service, method); err != nil { return err } + role := "response" + if cfg := annotations.GetMethodHTTPConfig(method); cfg != nil && cfg.Stream { + role = "SSE event" + } + if err := tscommon.CheckESMessageAnnotations( + service.GoName, method.GoName, "request", method.Input, + ); err != nil { + return err + } + if err := tscommon.CheckESMessageAnnotations( + service.GoName, method.GoName, role, method.Output, + ); err != nil { + return err + } } } @@ -464,7 +479,9 @@ func (g *Generator) generateURLBuilding(p printer, cfg *rpcMethodConfig) { for _, qp := range cfg.queryParams { // Handle repeated fields: use forEach + append for multi-value params if qp.Field != nil && qp.Field.Desc.IsList() { - p(" if (req.%s && req.%s.length > 0) req.%s.forEach(v => params.append(\"%s\", v));", + // String(v) coerces non-string element types (int32/bool/…) so the + // URLSearchParams.append call typechecks; it is a no-op for strings. + p(" if (req.%s && req.%s.length > 0) req.%s.forEach(v => params.append(\"%s\", String(v)));", qp.FieldJSONName, qp.FieldJSONName, qp.FieldJSONName, qp.ParamName) continue } diff --git a/internal/tsclientgen/inprocess_golden_test.go b/internal/tsclientgen/inprocess_golden_test.go index 1dbb9d40..136f3d63 100644 --- a/internal/tsclientgen/inprocess_golden_test.go +++ b/internal/tsclientgen/inprocess_golden_test.go @@ -74,6 +74,58 @@ func TestTSClientGenESRejectsEnumParams(t *testing.T) { } } +// TestTSClientGenESRejectsAnnotatedMessages asserts that in protobuf-es mode the +// generator fails loud when an RPC's request or response message closure carries +// a sebuf.http JSON-mapping annotation es-mode cannot honor. es-mode serializes +// with the canonical protojson codec, so any annotated proto would silently +// disagree with a sebuf Go server; the guard rejects it at generation time +// rather than emitting a second, incompatible wire format. Each fixture reuses +// the existing per-annotation hand-rolled test proto. +func TestTSClientGenESRejectsAnnotatedMessages(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping in-process es annotation-guard test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + + cases := []struct { + protoFile string + wantToken string + }{ + {"unwrap.proto", "unwrap"}, + {"timestamp_format.proto", "timestamp_format"}, + {"bytes_encoding.proto", "bytes_encoding"}, + {"nullable.proto", "nullable"}, + {"empty_behavior.proto", "empty_behavior"}, + {"flatten.proto", "flatten"}, + {"oneof_discriminator.proto", "oneof_config"}, + {"enum_encoding.proto", "enum_value"}, + {"int64_encoding.proto", "int64_encoding=NUMBER"}, + } + + for _, tc := range cases { + t.Run(tc.protoFile, func(t *testing.T) { + plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{tc.protoFile}) + genErr := New(plugin, tscommon.MessageRuntimeES).Generate() + if genErr == nil { + t.Fatalf("expected Generate() to fail for %s in es mode, but it succeeded", tc.protoFile) + } + if !strings.Contains(genErr.Error(), "ts_runtime=protobuf-es:") || + !strings.Contains(genErr.Error(), "cannot honor") { + t.Errorf("expected es JSON-mapping guard error, got: %v", genErr) + } + if !strings.Contains(genErr.Error(), tc.wantToken) { + t.Errorf("expected error to name annotation %q, got: %v", tc.wantToken, genErr) + } + }) + } +} + // TestTSClientGenInProcessReservedName drives a fixture whose message is named // ValidationError (a reserved error-helper symbol) and asserts Generate() fails // with a rename error, covering tscommon.CheckReservedNames via EmitSharedModules. diff --git a/internal/tsclientgen/testdata/golden/es/get_params_client.ts b/internal/tsclientgen/testdata/golden/es/get_params_client.ts index 29f13fbb..40f22170 100644 --- a/internal/tsclientgen/testdata/golden/es/get_params_client.ts +++ b/internal/tsclientgen/testdata/golden/es/get_params_client.ts @@ -1,9 +1,9 @@ // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: get_params.proto -import { fromJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; import { ApiError, ValidationError } from "./errors.js"; -import { GetItemRequestSchema, ItemSchema } from "./get_params_pb.js"; +import { GetItemRequestSchema, GetItemVersionRequestSchema, ItemSchema, UpdateItemRequestSchema } from "./get_params_pb.js"; import type { Item } from "./get_params_pb.js"; export interface GetParamsServiceClientOptions { @@ -55,6 +55,57 @@ export class GetParamsServiceClient { return fromJson(ItemSchema, await resp.json(), { ignoreUnknownFields: true }); } + async getItemVersion(req: MessageInitShape, options?: GetParamsServiceCallOptions): Promise { + let path = "/api/v1/items/{item_number}/versions/{version}/draft/{draft}"; + path = path.replace("{item_number}", encodeURIComponent(String(req.itemNumber))); + path = path.replace("{version}", encodeURIComponent(String(req.version))); + path = path.replace("{draft}", encodeURIComponent(String(req.draft))); + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return fromJson(ItemSchema, await resp.json(), { ignoreUnknownFields: true }); + } + + async updateItem(req: MessageInitShape, options?: GetParamsServiceCallOptions): Promise { + let path = "/api/v1/items/{item_number}"; + path = path.replace("{item_number}", encodeURIComponent(String(req.itemNumber))); + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(toJson(UpdateItemRequestSchema, create(UpdateItemRequestSchema, req))), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return fromJson(ItemSchema, await resp.json(), { ignoreUnknownFields: true }); + } + private async handleError(resp: Response): Promise { const body = await resp.text(); if (resp.status === 400) { diff --git a/internal/tsclientgen/testdata/golden/es/get_params_pb.ts b/internal/tsclientgen/testdata/golden/es/get_params_pb.ts index f4ff0e35..1220dc0a 100644 --- a/internal/tsclientgen/testdata/golden/es/get_params_pb.ts +++ b/internal/tsclientgen/testdata/golden/es/get_params_pb.ts @@ -11,7 +11,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file get_params.proto. */ export const file_get_params: GenFile = /*@__PURE__*/ - fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCIgCgRJdGVtEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkyfAoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAEaDaK1GAkKBy9hcGkvdjFCT1pNZ2l0aHViLmNvbS9TZWJhc3RpZW5NZWxraS9zZWJ1Zi9pbnRlcm5hbC9odHRwZ2VuL3Rlc3RkYXRhL2dlbmVyYXRlZDtnZW5lcmF0ZWRiBnByb3RvMw", [file_sebuf_http_annotations]); + fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCJMChVHZXRJdGVtVmVyc2lvblJlcXVlc3QSEwoLaXRlbV9udW1iZXIYASABKAMSDwoHdmVyc2lvbhgCIAEoBRINCgVkcmFmdBgDIAEoCCI2ChFVcGRhdGVJdGVtUmVxdWVzdBITCgtpdGVtX251bWJlchgBIAEoAxIMCgRuYW1lGAIgASgJIiAKBEl0ZW0SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCTLwAgoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAESjAEKDkdldEl0ZW1WZXJzaW9uEiUudGVzdC5nZXRwYXJhbXMuR2V0SXRlbVZlcnNpb25SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSI9mrUYOQo1L2l0ZW1zL3tpdGVtX251bWJlcn0vdmVyc2lvbnMve3ZlcnNpb259L2RyYWZ0L3tkcmFmdH0QARJjCgpVcGRhdGVJdGVtEiEudGVzdC5nZXRwYXJhbXMuVXBkYXRlSXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhyatRgYChQvaXRlbXMve2l0ZW1fbnVtYmVyfRACGg2itRgJCgcvYXBpL3YxQk9aTWdpdGh1Yi5jb20vU2ViYXN0aWVuTWVsa2kvc2VidWYvaW50ZXJuYWwvaHR0cGdlbi90ZXN0ZGF0YS9nZW5lcmF0ZWQ7Z2VuZXJhdGVkYgZwcm90bzM", [file_sebuf_http_annotations]); /** * @generated from message test.getparams.GetItemRequest @@ -49,6 +49,59 @@ export type GetItemRequest = Message<"test.getparams.GetItemRequest"> & { export const GetItemRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_get_params, 0); +/** + * @generated from message test.getparams.GetItemVersionRequest + */ +export type GetItemVersionRequest = Message<"test.getparams.GetItemVersionRequest"> & { + /** + * Non-string path parameters (coerced from URL strings). + * + * @generated from field: int64 item_number = 1; + */ + itemNumber: bigint; + + /** + * @generated from field: int32 version = 2; + */ + version: number; + + /** + * @generated from field: bool draft = 3; + */ + draft: boolean; +}; + +/** + * Describes the message test.getparams.GetItemVersionRequest. + * Use `create(GetItemVersionRequestSchema)` to create a new message. + */ +export const GetItemVersionRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 1); + +/** + * @generated from message test.getparams.UpdateItemRequest + */ +export type UpdateItemRequest = Message<"test.getparams.UpdateItemRequest"> & { + /** + * Non-string path parameter merged into the request body. + * + * @generated from field: int64 item_number = 1; + */ + itemNumber: bigint; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message test.getparams.UpdateItemRequest. + * Use `create(UpdateItemRequestSchema)` to create a new message. + */ +export const UpdateItemRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 2); + /** * @generated from message test.getparams.Item */ @@ -69,13 +122,15 @@ export type Item = Message<"test.getparams.Item"> & { * Use `create(ItemSchema)` to create a new message. */ export const ItemSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_get_params, 1); + messageDesc(file_get_params, 3); /** - * GetParamsService exercises a unary GET RPC with a string path parameter and - * scalar (non-enum) query parameters under protobuf-es mode. This locks down - * the path/query surface that the POST-unary and GET-streaming es fixtures do - * not cover. + * GetParamsService exercises unary RPCs with path and query parameters under + * protobuf-es mode: a string path param, scalar (non-enum) query params, and + * non-string (numeric/bool) path params that must be coerced from their raw URL + * string form into the strongly typed protobuf-es message shapes. This locks + * down the path/query surface that the POST-unary and GET-streaming es fixtures + * do not cover. * * @generated from service test.getparams.GetParamsService */ @@ -88,6 +143,29 @@ export const GetParamsService: GenService<{ input: typeof GetItemRequestSchema; output: typeof ItemSchema; }, + /** + * Non-string path params on a GET: item_number (bigint), version (number), + * and draft (boolean) must be coerced from their raw string form when the + * request object is built via create(Schema, {...}). + * + * @generated from rpc test.getparams.GetParamsService.GetItemVersion + */ + getItemVersion: { + methodKind: "unary"; + input: typeof GetItemVersionRequestSchema; + output: typeof ItemSchema; + }, + /** + * Non-string path param merged into a POST body: item_number (bigint) must be + * coerced before assignment onto the branded request message. + * + * @generated from rpc test.getparams.GetParamsService.UpdateItem + */ + updateItem: { + methodKind: "unary"; + input: typeof UpdateItemRequestSchema; + output: typeof ItemSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_get_params, 0); diff --git a/internal/tsclientgen/testdata/golden/query_params_client.ts b/internal/tsclientgen/testdata/golden/query_params_client.ts index df4c128d..e4d1becc 100644 --- a/internal/tsclientgen/testdata/golden/query_params_client.ts +++ b/internal/tsclientgen/testdata/golden/query_params_client.ts @@ -144,11 +144,11 @@ export class QueryParamServiceClient { let path = "/api/search/advanced"; const params = new URLSearchParams(); if (req.region != null && req.region !== "unspecified") params.set("region", String(req.region)); - if (req.countries && req.countries.length > 0) req.countries.forEach(v => params.append("countries", v)); + if (req.countries && req.countries.length > 0) req.countries.forEach(v => params.append("countries", String(v))); if (req.keyword != null && req.keyword !== "") params.set("keyword", String(req.keyword)); - if (req.years && req.years.length > 0) req.years.forEach(v => params.append("years", v)); - if (req.flags && req.flags.length > 0) req.flags.forEach(v => params.append("flags", v)); - if (req.regions && req.regions.length > 0) req.regions.forEach(v => params.append("regions", v)); + if (req.years && req.years.length > 0) req.years.forEach(v => params.append("years", String(v))); + if (req.flags && req.flags.length > 0) req.flags.forEach(v => params.append("flags", String(v))); + if (req.regions && req.regions.length > 0) req.regions.forEach(v => params.append("regions", String(v))); const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); const headers: Record = { diff --git a/internal/tsclientgen/testdata/proto/get_params.proto b/internal/tsclientgen/testdata/proto/get_params.proto index e73ebe2b..a4bf76f7 100644 --- a/internal/tsclientgen/testdata/proto/get_params.proto +++ b/internal/tsclientgen/testdata/proto/get_params.proto @@ -6,10 +6,12 @@ option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/g import "sebuf/http/annotations.proto"; -// GetParamsService exercises a unary GET RPC with a string path parameter and -// scalar (non-enum) query parameters under protobuf-es mode. This locks down -// the path/query surface that the POST-unary and GET-streaming es fixtures do -// not cover. +// GetParamsService exercises unary RPCs with path and query parameters under +// protobuf-es mode: a string path param, scalar (non-enum) query params, and +// non-string (numeric/bool) path params that must be coerced from their raw URL +// string form into the strongly typed protobuf-es message shapes. This locks +// down the path/query surface that the POST-unary and GET-streaming es fixtures +// do not cover. service GetParamsService { option (sebuf.http.service_config) = { base_path: "/api/v1" @@ -21,6 +23,25 @@ service GetParamsService { method: HTTP_METHOD_GET }; } + + // Non-string path params on a GET: item_number (bigint), version (number), + // and draft (boolean) must be coerced from their raw string form when the + // request object is built via create(Schema, {...}). + rpc GetItemVersion(GetItemVersionRequest) returns (Item) { + option (sebuf.http.config) = { + path: "/items/{item_number}/versions/{version}/draft/{draft}" + method: HTTP_METHOD_GET + }; + } + + // Non-string path param merged into a POST body: item_number (bigint) must be + // coerced before assignment onto the branded request message. + rpc UpdateItem(UpdateItemRequest) returns (Item) { + option (sebuf.http.config) = { + path: "/items/{item_number}" + method: HTTP_METHOD_POST + }; + } } message GetItemRequest { @@ -32,6 +53,19 @@ message GetItemRequest { bool include_archived = 4 [(sebuf.http.query) = { name: "include_archived" }]; } +message GetItemVersionRequest { + // Non-string path parameters (coerced from URL strings). + int64 item_number = 1; + int32 version = 2; + bool draft = 3; +} + +message UpdateItemRequest { + // Non-string path parameter merged into the request body. + int64 item_number = 1; + string name = 2; +} + message Item { string id = 1; string name = 2; diff --git a/internal/tscommon/es_guard.go b/internal/tscommon/es_guard.go new file mode 100644 index 00000000..37c24ead --- /dev/null +++ b/internal/tscommon/es_guard.go @@ -0,0 +1,123 @@ +package tscommon + +import ( + "fmt" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/SebastienMelki/sebuf/http" + "github.com/SebastienMelki/sebuf/internal/annotations" +) + +// CheckESMessageAnnotations walks the transitive message closure reachable from +// root and returns a generation-time error if any field, message, or oneof +// carries a sebuf.http JSON-mapping annotation that protobuf-es runtime mode +// cannot honor. +// +// protobuf-es mode serializes with the runtime's canonical protojson codec +// (toJson/fromJson). sebuf's Go server, by contrast, layers an annotation-aware +// transform (MarshalJSONSebuf) on top of protojson that deliberately diverges +// from canonical protojson wherever a JSON-mapping annotation is set. That +// transform layer has not been ported to TypeScript, so any annotated proto put +// on the wire in es mode would silently disagree with the Go server (throw on +// decode or drop data). Rather than emit such code, the generator fails loud +// here for the whole 🔴/🟡 annotation set. +// +// role is a short human label for where root sits on the RPC ("request", +// "response", or "SSE event") and is only used in the error message. The walk is +// cycle-safe via a visited set keyed on message full name. +func CheckESMessageAnnotations(service, method, role string, root *protogen.Message) error { + visited := make(map[protoreflect.FullName]bool) + return walkESMessageClosure(service, method, role, root, visited) +} + +func walkESMessageClosure( + service, method, role string, + msg *protogen.Message, + visited map[protoreflect.FullName]bool, +) error { + if msg == nil { + return nil + } + name := msg.Desc.FullName() + if visited[name] { + return nil + } + visited[name] = true + + // Message-level annotations. + if annotations.FindUnwrapField(msg) != nil { + return unsupportedESAnnotationError(service, method, role, "unwrap", "message "+string(msg.Desc.Name())) + } + if annotations.HasOneofDiscriminator(msg) { + return unsupportedESAnnotationError( + service, method, role, "oneof_config", "message "+string(msg.Desc.Name()), + ) + } + + for _, field := range msg.Fields { + if err := checkESField(service, method, role, msg, field); err != nil { + return err + } + // Recurse into message-typed fields. For map fields, field.Message is the + // synthetic map-entry message, and recursing into it reaches the value + // type (and its annotations) via the entry's value field. + if field.Message != nil { + if err := walkESMessageClosure(service, method, role, field.Message, visited); err != nil { + return err + } + } + } + return nil +} + +// checkESField reports the first unsupported JSON-mapping annotation on a single +// field, or nil if the field is representable under canonical protojson. +func checkESField( + service, method, role string, + msg *protogen.Message, + field *protogen.Field, +) error { + location := fmt.Sprintf("field %s.%s", msg.Desc.Name(), field.Desc.Name()) + + var annotation string + switch { + case annotations.HasUnwrapAnnotation(field): + annotation = "unwrap" + case annotations.IsFlattenField(field): + annotation = "flatten" + case annotations.HasTimestampFormatAnnotation(field): + annotation = "timestamp_format" + case annotations.HasBytesEncodingAnnotation(field): + annotation = "bytes_encoding" + case annotations.IsNullableField(field): + annotation = "nullable" + case annotations.HasEmptyBehaviorAnnotation(field): + annotation = "empty_behavior" + case annotations.IsInt64NumberEncoding(field): + annotation = "int64_encoding=NUMBER" + case field.Enum != nil && annotations.GetEnumEncoding(field) == http.EnumEncoding_ENUM_ENCODING_NUMBER: + annotation = "enum_encoding=NUMBER" + case field.Enum != nil && annotations.HasAnyEnumValueMapping(field.Enum): + annotation = "enum_value" + default: + return nil + } + + return unsupportedESAnnotationError(service, method, role, annotation, location) +} + +// unsupportedESAnnotationError builds the generation-time error returned when a +// JSON-mapping annotation es-mode cannot honor is found in an RPC's message +// closure. location names where the annotation sits (e.g. "field User.status" +// or "message UsersResponse"). +func unsupportedESAnnotationError(service, method, role, annotation, location string) error { + return fmt.Errorf( + "ts_runtime=protobuf-es: %s uses the %s JSON-mapping annotation (reachable from the %s of %s.%s), "+ + "which es-mode cannot honor. es-mode speaks canonical protojson and does not apply sebuf's "+ + "JSON-mapping transforms, so this proto would not be wire-compatible with a sebuf server. "+ + "Use ts_runtime=hand-rolled for this service, or remove the annotation", + location, annotation, role, service, method, + ) +} diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index 190f41b5..f1c99888 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -279,11 +279,26 @@ func (g *Generator) buildRPCRouteConfig(service *protogen.Service, method *proto // Enum path/query parameters are not representable in protobuf-es mode // (numeric enums vs string URL params); fail loud rather than emit code that - // fails downstream tsc. + // fails downstream tsc. Body/response messages carrying JSON-mapping + // annotations es-mode cannot honor are rejected the same way. if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { if enumErr := checkNoEnumParamsES(serviceName, methodName, pathParamFields, queryParams); enumErr != nil { return nil, enumErr } + role := "response" + if httpConfig != nil && httpConfig.Stream { + role = "SSE event" + } + if annErr := tscommon.CheckESMessageAnnotations( + serviceName, methodName, "request", method.Input, + ); annErr != nil { + return nil, annErr + } + if annErr := tscommon.CheckESMessageAnnotations( + serviceName, methodName, role, method.Output, + ); annErr != nil { + return nil, annErr + } } return &rpcRouteConfig{ @@ -625,11 +640,41 @@ func (g *Generator) emitPathParamAssignment( "%s%s: pathParams[\"%s\"] as %s%s", prefix, ppf.jsonName, ppf.protoName, enumName, suffix, ) + } else if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { + // protobuf-es MessageInitShape is strongly typed, so a raw string path + // param won't assign to a numeric/bool field. Coerce to the field's type. + raw := fmt.Sprintf("pathParams[%q]", ppf.protoName) + p("%s%s: %s%s", prefix, ppf.jsonName, esPathParamInitExpr(ppf.field, raw), suffix) } else { p("%s%s: pathParams[\"%s\"]%s", prefix, ppf.jsonName, ppf.protoName, suffix) } } +// esPathParamInitExpr returns the TypeScript expression that coerces the raw +// string path param expression `raw` into the type protobuf-es expects for +// field. Path params arrive as strings on the wire but protobuf-es messages type +// numeric fields as number, 64-bit fields as bigint, and bool as boolean. +// Enum fields never reach here (rejected up front by checkNoEnumParamsES); +// string and any other kind pass through unchanged. +func esPathParamInitExpr(field *protogen.Field, raw string) string { + if field == nil { + return raw + } + switch field.Desc.Kind() { + case protoreflect.BoolKind: + return raw + ` === "true"` + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind, + protoreflect.FloatKind, protoreflect.DoubleKind: + return "Number(" + raw + ")" + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return "BigInt(" + raw + ")" + default: + return raw + } +} + // generatePathParamMerge generates code to merge path params into the request body. // This ensures the handler receives a fully populated request, matching Go generator behavior. func (g *Generator) generatePathParamMerge(p tscommon.Printer, cfg *rpcRouteConfig, _ *protogen.Method) { @@ -645,6 +690,11 @@ func (g *Generator) generatePathParamMerge(p tscommon.Printer, cfg *rpcRouteConf " body.%s = pathParams[\"%s\"] as %s;", ppf.jsonName, ppf.protoName, enumName, ) + } else if g.ctx.MessageRuntime == tscommon.MessageRuntimeES { + // body is a branded protobuf-es message (from fromJson); its numeric/ + // bool fields won't accept a raw string, so coerce to the field's type. + raw := fmt.Sprintf("pathParams[%q]", ppf.protoName) + p(" body.%s = %s;", ppf.jsonName, esPathParamInitExpr(ppf.field, raw)) } else { p(" body.%s = pathParams[\"%s\"];", ppf.jsonName, ppf.protoName) } diff --git a/internal/tsservergen/inprocess_golden_test.go b/internal/tsservergen/inprocess_golden_test.go index 4da8f05d..770cd0c3 100644 --- a/internal/tsservergen/inprocess_golden_test.go +++ b/internal/tsservergen/inprocess_golden_test.go @@ -120,6 +120,58 @@ func TestTSServerGenESRejectsEnumParams(t *testing.T) { } } +// TestTSServerGenESRejectsAnnotatedMessages asserts that in protobuf-es mode the +// generator fails loud when an RPC's request or response message closure carries +// a sebuf.http JSON-mapping annotation es-mode cannot honor. es-mode serializes +// with the canonical protojson codec, so any annotated proto would silently +// disagree with a sebuf Go server; the guard rejects it at generation time +// rather than emitting a second, incompatible wire format. Each fixture reuses +// the existing per-annotation hand-rolled test proto. +func TestTSServerGenESRejectsAnnotatedMessages(t *testing.T) { + if _, err := exec.LookPath("protoc"); err != nil { + t.Skip("protoc not found, skipping in-process es annotation-guard test") + } + + baseDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + projectRoot := filepath.Join(baseDir, "..", "..") + protoDir := filepath.Join(baseDir, "testdata", "proto") + + cases := []struct { + protoFile string + wantToken string + }{ + {"unwrap.proto", "unwrap"}, + {"timestamp_format.proto", "timestamp_format"}, + {"bytes_encoding.proto", "bytes_encoding"}, + {"nullable.proto", "nullable"}, + {"empty_behavior.proto", "empty_behavior"}, + {"flatten.proto", "flatten"}, + {"oneof_discriminator.proto", "oneof_config"}, + {"enum_encoding.proto", "enum_value"}, + {"int64_encoding.proto", "int64_encoding=NUMBER"}, + } + + for _, tc := range cases { + t.Run(tc.protoFile, func(t *testing.T) { + plugin := buildInProcessPlugin(t, protoDir, projectRoot, []string{tc.protoFile}) + genErr := New(plugin, tscommon.MessageRuntimeES).Generate() + if genErr == nil { + t.Fatalf("expected Generate() to fail for %s in es mode, but it succeeded", tc.protoFile) + } + if !strings.Contains(genErr.Error(), "ts_runtime=protobuf-es:") || + !strings.Contains(genErr.Error(), "cannot honor") { + t.Errorf("expected es JSON-mapping guard error, got: %v", genErr) + } + if !strings.Contains(genErr.Error(), tc.wantToken) { + t.Errorf("expected error to name annotation %q, got: %v", tc.wantToken, genErr) + } + }) + } +} + // TestTSServerGenInProcessReservedName drives a fixture whose message is named // ValidationError (a reserved error-helper symbol) and asserts Generate() fails // with a rename error, covering tscommon.CheckReservedNames via EmitSharedModules. diff --git a/internal/tsservergen/testdata/golden/es/get_params_pb.ts b/internal/tsservergen/testdata/golden/es/get_params_pb.ts index f4ff0e35..1220dc0a 100644 --- a/internal/tsservergen/testdata/golden/es/get_params_pb.ts +++ b/internal/tsservergen/testdata/golden/es/get_params_pb.ts @@ -11,7 +11,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file get_params.proto. */ export const file_get_params: GenFile = /*@__PURE__*/ - fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCIgCgRJdGVtEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkyfAoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAEaDaK1GAkKBy9hcGkvdjFCT1pNZ2l0aHViLmNvbS9TZWJhc3RpZW5NZWxraS9zZWJ1Zi9pbnRlcm5hbC9odHRwZ2VuL3Rlc3RkYXRhL2dlbmVyYXRlZDtnZW5lcmF0ZWRiBnByb3RvMw", [file_sebuf_http_annotations]); + fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCJMChVHZXRJdGVtVmVyc2lvblJlcXVlc3QSEwoLaXRlbV9udW1iZXIYASABKAMSDwoHdmVyc2lvbhgCIAEoBRINCgVkcmFmdBgDIAEoCCI2ChFVcGRhdGVJdGVtUmVxdWVzdBITCgtpdGVtX251bWJlchgBIAEoAxIMCgRuYW1lGAIgASgJIiAKBEl0ZW0SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCTLwAgoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAESjAEKDkdldEl0ZW1WZXJzaW9uEiUudGVzdC5nZXRwYXJhbXMuR2V0SXRlbVZlcnNpb25SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSI9mrUYOQo1L2l0ZW1zL3tpdGVtX251bWJlcn0vdmVyc2lvbnMve3ZlcnNpb259L2RyYWZ0L3tkcmFmdH0QARJjCgpVcGRhdGVJdGVtEiEudGVzdC5nZXRwYXJhbXMuVXBkYXRlSXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhyatRgYChQvaXRlbXMve2l0ZW1fbnVtYmVyfRACGg2itRgJCgcvYXBpL3YxQk9aTWdpdGh1Yi5jb20vU2ViYXN0aWVuTWVsa2kvc2VidWYvaW50ZXJuYWwvaHR0cGdlbi90ZXN0ZGF0YS9nZW5lcmF0ZWQ7Z2VuZXJhdGVkYgZwcm90bzM", [file_sebuf_http_annotations]); /** * @generated from message test.getparams.GetItemRequest @@ -49,6 +49,59 @@ export type GetItemRequest = Message<"test.getparams.GetItemRequest"> & { export const GetItemRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_get_params, 0); +/** + * @generated from message test.getparams.GetItemVersionRequest + */ +export type GetItemVersionRequest = Message<"test.getparams.GetItemVersionRequest"> & { + /** + * Non-string path parameters (coerced from URL strings). + * + * @generated from field: int64 item_number = 1; + */ + itemNumber: bigint; + + /** + * @generated from field: int32 version = 2; + */ + version: number; + + /** + * @generated from field: bool draft = 3; + */ + draft: boolean; +}; + +/** + * Describes the message test.getparams.GetItemVersionRequest. + * Use `create(GetItemVersionRequestSchema)` to create a new message. + */ +export const GetItemVersionRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 1); + +/** + * @generated from message test.getparams.UpdateItemRequest + */ +export type UpdateItemRequest = Message<"test.getparams.UpdateItemRequest"> & { + /** + * Non-string path parameter merged into the request body. + * + * @generated from field: int64 item_number = 1; + */ + itemNumber: bigint; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message test.getparams.UpdateItemRequest. + * Use `create(UpdateItemRequestSchema)` to create a new message. + */ +export const UpdateItemRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_get_params, 2); + /** * @generated from message test.getparams.Item */ @@ -69,13 +122,15 @@ export type Item = Message<"test.getparams.Item"> & { * Use `create(ItemSchema)` to create a new message. */ export const ItemSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_get_params, 1); + messageDesc(file_get_params, 3); /** - * GetParamsService exercises a unary GET RPC with a string path parameter and - * scalar (non-enum) query parameters under protobuf-es mode. This locks down - * the path/query surface that the POST-unary and GET-streaming es fixtures do - * not cover. + * GetParamsService exercises unary RPCs with path and query parameters under + * protobuf-es mode: a string path param, scalar (non-enum) query params, and + * non-string (numeric/bool) path params that must be coerced from their raw URL + * string form into the strongly typed protobuf-es message shapes. This locks + * down the path/query surface that the POST-unary and GET-streaming es fixtures + * do not cover. * * @generated from service test.getparams.GetParamsService */ @@ -88,6 +143,29 @@ export const GetParamsService: GenService<{ input: typeof GetItemRequestSchema; output: typeof ItemSchema; }, + /** + * Non-string path params on a GET: item_number (bigint), version (number), + * and draft (boolean) must be coerced from their raw string form when the + * request object is built via create(Schema, {...}). + * + * @generated from rpc test.getparams.GetParamsService.GetItemVersion + */ + getItemVersion: { + methodKind: "unary"; + input: typeof GetItemVersionRequestSchema; + output: typeof ItemSchema; + }, + /** + * Non-string path param merged into a POST body: item_number (bigint) must be + * coerced before assignment onto the branded request message. + * + * @generated from rpc test.getparams.GetParamsService.UpdateItem + */ + updateItem: { + methodKind: "unary"; + input: typeof UpdateItemRequestSchema; + output: typeof ItemSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_get_params, 0); diff --git a/internal/tsservergen/testdata/golden/es/get_params_server.ts b/internal/tsservergen/testdata/golden/es/get_params_server.ts index 74501b14..8f1fe907 100644 --- a/internal/tsservergen/testdata/golden/es/get_params_server.ts +++ b/internal/tsservergen/testdata/golden/es/get_params_server.ts @@ -1,10 +1,10 @@ // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: get_params.proto -import { create, toJson, type MessageInitShape } from "@bufbuild/protobuf"; +import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf"; import { FieldViolation, ValidationError } from "./errors.js"; -import { GetItemRequestSchema, ItemSchema } from "./get_params_pb.js"; -import type { GetItemRequest } from "./get_params_pb.js"; +import { GetItemRequestSchema, GetItemVersionRequestSchema, ItemSchema, UpdateItemRequestSchema } from "./get_params_pb.js"; +import type { GetItemRequest, GetItemVersionRequest, UpdateItemRequest } from "./get_params_pb.js"; export interface ServerContext { request: Request; @@ -25,6 +25,8 @@ export interface RouteDescriptor { export interface GetParamsServiceHandler { getItem(ctx: ServerContext, req: GetItemRequest): Promise>; + getItemVersion(ctx: ServerContext, req: GetItemVersionRequest): Promise>; + updateItem(ctx: ServerContext, req: UpdateItemRequest): Promise>; } export function createGetParamsServiceRoutes( @@ -85,6 +87,102 @@ export function createGetParamsServiceRoutes( } }, }, + { + method: "GET", + path: "/api/v1/items/{item_number}/versions/{version}/draft/{draft}", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const pathSegments = url.pathname.split("/"); + pathParams["item_number"] = decodeURIComponent(pathSegments[4] ?? ""); + pathParams["version"] = decodeURIComponent(pathSegments[6] ?? ""); + pathParams["draft"] = decodeURIComponent(pathSegments[8] ?? ""); + + const body = create(GetItemVersionRequestSchema, { + itemNumber: BigInt(pathParams["item_number"]), + version: Number(pathParams["version"]), + draft: pathParams["draft"] === "true", + }); + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getItemVersion(ctx, body); + return new Response(JSON.stringify(toJson(ItemSchema, create(ItemSchema, result))), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/v1/items/{item_number}", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const pathSegments = url.pathname.split("/"); + pathParams["item_number"] = decodeURIComponent(pathSegments[4] ?? ""); + + const body = fromJson(UpdateItemRequestSchema, await req.json(), { ignoreUnknownFields: true }); + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("updateItem", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + body.itemNumber = BigInt(pathParams["item_number"]); + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.updateItem(ctx, body); + return new Response(JSON.stringify(toJson(ItemSchema, create(ItemSchema, result))), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, ]; } diff --git a/internal/tsservergen/testdata/proto/get_params.proto b/internal/tsservergen/testdata/proto/get_params.proto index e73ebe2b..a4bf76f7 100644 --- a/internal/tsservergen/testdata/proto/get_params.proto +++ b/internal/tsservergen/testdata/proto/get_params.proto @@ -6,10 +6,12 @@ option go_package = "github.com/SebastienMelki/sebuf/internal/httpgen/testdata/g import "sebuf/http/annotations.proto"; -// GetParamsService exercises a unary GET RPC with a string path parameter and -// scalar (non-enum) query parameters under protobuf-es mode. This locks down -// the path/query surface that the POST-unary and GET-streaming es fixtures do -// not cover. +// GetParamsService exercises unary RPCs with path and query parameters under +// protobuf-es mode: a string path param, scalar (non-enum) query params, and +// non-string (numeric/bool) path params that must be coerced from their raw URL +// string form into the strongly typed protobuf-es message shapes. This locks +// down the path/query surface that the POST-unary and GET-streaming es fixtures +// do not cover. service GetParamsService { option (sebuf.http.service_config) = { base_path: "/api/v1" @@ -21,6 +23,25 @@ service GetParamsService { method: HTTP_METHOD_GET }; } + + // Non-string path params on a GET: item_number (bigint), version (number), + // and draft (boolean) must be coerced from their raw string form when the + // request object is built via create(Schema, {...}). + rpc GetItemVersion(GetItemVersionRequest) returns (Item) { + option (sebuf.http.config) = { + path: "/items/{item_number}/versions/{version}/draft/{draft}" + method: HTTP_METHOD_GET + }; + } + + // Non-string path param merged into a POST body: item_number (bigint) must be + // coerced before assignment onto the branded request message. + rpc UpdateItem(UpdateItemRequest) returns (Item) { + option (sebuf.http.config) = { + path: "/items/{item_number}" + method: HTTP_METHOD_POST + }; + } } message GetItemRequest { @@ -32,6 +53,19 @@ message GetItemRequest { bool include_archived = 4 [(sebuf.http.query) = { name: "include_archived" }]; } +message GetItemVersionRequest { + // Non-string path parameters (coerced from URL strings). + int64 item_number = 1; + int32 version = 2; + bool draft = 3; +} + +message UpdateItemRequest { + // Non-string path parameter merged into the request body. + int64 item_number = 1; + string name = 2; +} + message Item { string id = 1; string name = 2; From bed82f3967a478984ae7cd556d5473df421dfe61 Mon Sep 17 00:00:00 2001 From: Hicham <53556927+hishamank@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:16:37 +0300 Subject: [PATCH 15/15] fix(ts): coerce repeated non-string query params in es-mode + document roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the server-side mirror of the client repeated-query fix: in protobuf-es mode a repeated non-string query param was emitted as `params.getAll("x")` (string[]) into a strongly-typed protobuf-es element type (number[]/bigint[]/ boolean[]), which fails tsc. Coerce per element via esQueryListInitExpr — .map(Number) for 32-bit/float, .map(BigInt) for 64-bit, .map(v => v === "true") for bool, string[] verbatim. Enum lists never reach here (rejected up front by checkNoEnumParamsES). Hand-rolled mode is unchanged. Extend the get_params es fixture with repeated string/int32/int64/bool query params so the es golden `tsc --noEmit` typecheck compiles the coercion. Document the es-mode roadmap in docs/client-generation.md: the conformance harness (cross-runtime byte-equivalence oracle from the Go consistency tests), the transform layer (TS analog of MarshalJSONSebuf as a runtime shim over toJson/fromJson), and the annotation-by-annotation order for lifting the fail-loud guard. Co-Authored-By: Claude Opus 4.8 --- docs/client-generation.md | 35 ++++++++++++++++++ .../testdata/golden/es/get_params_client.ts | 4 ++ .../testdata/golden/es/get_params_pb.ts | 26 ++++++++++++- .../testdata/proto/get_params.proto | 7 ++++ internal/tsservergen/generator.go | 37 ++++++++++++++++++- .../testdata/golden/es/get_params_pb.ts | 26 ++++++++++++- .../testdata/golden/es/get_params_server.ts | 4 ++ .../testdata/proto/get_params.proto | 7 ++++ 8 files changed, 142 insertions(+), 4 deletions(-) diff --git a/docs/client-generation.md b/docs/client-generation.md index 2d2718f4..7f2b5a23 100644 --- a/docs/client-generation.md +++ b/docs/client-generation.md @@ -818,6 +818,41 @@ ignored rather than causing an error). `"undefined"` at runtime. Hand-rolled mode's request interfaces mark such fields required; in es-mode this check is deferred to the server's 4xx. +### Roadmap — lifting the guard + +es-mode ships as a **preview**: correct and safe for protos that use no +`sebuf.http` JSON-mapping annotation, and fail-loud for the rest. The plan to +make it a true drop-in replacement for annotated protos — matching what the Go +server does with `MarshalJSONSebuf` — is staged so the fail-loud guard can be +lifted **one annotation at a time**, each only after it is proven wire-correct. + +1. **Conformance harness (prerequisite for everything below).** A cross-runtime + test that asserts es-mode's on-the-wire bytes are **byte-equivalent** to a + sebuf Go server for a given `(message, annotation)` case — encode *and* + decode. The Go marshal pipeline already has per-annotation consistency tests + (`internal/httpgen/*_consistency_test.go`) that pin the expected wire; those + become the oracle. Start with captured golden wire fixtures (deterministic, no + network), add a handful of live Go-server round-trips for the trickiest + annotations. This harness is what lets us trust each transform and is the gate + for removing an annotation from the guard. +2. **Transform layer (the TS analog of `MarshalJSONSebuf`).** The Go layer is not + a bespoke serializer — it post-processes canonical protojson: `protojson` + marshal → rewrite only the annotated JSON keys → emit. The same shape ports to + TypeScript over `toJson`/`fromJson`. Because protobuf-es exposes the message + `Schema` (`DescMessage`) at runtime, this can be a single hand-written runtime + shim (`sebufToJson`/`sebufFromJson`) driven by a small per-field annotation + table the plugin emits — not per-message codegen. Implement annotation by + annotation; as each passes the conformance harness, drop it from + `CheckESMessageAnnotations`. +3. **Suggested order** (cheapest / highest-value first): `int64_encoding`, + `enum_value`, `enum_encoding`, `timestamp_format`, `bytes_encoding`, + `nullable`, `empty_behavior`, then the structural ones — `flatten` / + `flatten_prefix`, `unwrap`, and `oneof_config` flatten. Each lands with its + own conformance case and a guard-lift in the same change. + +Until an annotation reaches step 2 with green conformance, es-mode rejects it at +generation time — no silent wire divergence. + ## See Also - **[HTTP Generation Guide](./http-generation.md)** - Go server-side handler generation diff --git a/internal/tsclientgen/testdata/golden/es/get_params_client.ts b/internal/tsclientgen/testdata/golden/es/get_params_client.ts index 40f22170..b6dc3eea 100644 --- a/internal/tsclientgen/testdata/golden/es/get_params_client.ts +++ b/internal/tsclientgen/testdata/golden/es/get_params_client.ts @@ -34,6 +34,10 @@ export class GetParamsServiceClient { if (req.query != null && req.query !== "") params.set("q", String(req.query)); if (req.limit != null && req.limit !== 0) params.set("limit", String(req.limit)); if (req.includeArchived) params.set("include_archived", String(req.includeArchived)); + if (req.tags && req.tags.length > 0) req.tags.forEach(v => params.append("tags", String(v))); + if (req.sizes && req.sizes.length > 0) req.sizes.forEach(v => params.append("sizes", String(v))); + if (req.ids && req.ids.length > 0) req.ids.forEach(v => params.append("ids", String(v))); + if (req.flags && req.flags.length > 0) req.flags.forEach(v => params.append("flags", String(v))); const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); const headers: Record = { diff --git a/internal/tsclientgen/testdata/golden/es/get_params_pb.ts b/internal/tsclientgen/testdata/golden/es/get_params_pb.ts index 1220dc0a..260d418d 100644 --- a/internal/tsclientgen/testdata/golden/es/get_params_pb.ts +++ b/internal/tsclientgen/testdata/golden/es/get_params_pb.ts @@ -11,7 +11,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file get_params.proto. */ export const file_get_params: GenFile = /*@__PURE__*/ - fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCJMChVHZXRJdGVtVmVyc2lvblJlcXVlc3QSEwoLaXRlbV9udW1iZXIYASABKAMSDwoHdmVyc2lvbhgCIAEoBRINCgVkcmFmdBgDIAEoCCI2ChFVcGRhdGVJdGVtUmVxdWVzdBITCgtpdGVtX251bWJlchgBIAEoAxIMCgRuYW1lGAIgASgJIiAKBEl0ZW0SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCTLwAgoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAESjAEKDkdldEl0ZW1WZXJzaW9uEiUudGVzdC5nZXRwYXJhbXMuR2V0SXRlbVZlcnNpb25SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSI9mrUYOQo1L2l0ZW1zL3tpdGVtX251bWJlcn0vdmVyc2lvbnMve3ZlcnNpb259L2RyYWZ0L3tkcmFmdH0QARJjCgpVcGRhdGVJdGVtEiEudGVzdC5nZXRwYXJhbXMuVXBkYXRlSXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhyatRgYChQvaXRlbXMve2l0ZW1fbnVtYmVyfRACGg2itRgJCgcvYXBpL3YxQk9aTWdpdGh1Yi5jb20vU2ViYXN0aWVuTWVsa2kvc2VidWYvaW50ZXJuYWwvaHR0cGdlbi90ZXN0ZGF0YS9nZW5lcmF0ZWQ7Z2VuZXJhdGVkYgZwcm90bzM", [file_sebuf_http_annotations]); + fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyLxAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZBIYCgR0YWdzGAUgAygJQgrCtRgGCgR0YWdzEhoKBXNpemVzGAYgAygFQgvCtRgHCgVzaXplcxIWCgNpZHMYByADKANCCcK1GAUKA2lkcxIaCgVmbGFncxgIIAMoCEILwrUYBwoFZmxhZ3MiTAoVR2V0SXRlbVZlcnNpb25SZXF1ZXN0EhMKC2l0ZW1fbnVtYmVyGAEgASgDEg8KB3ZlcnNpb24YAiABKAUSDQoFZHJhZnQYAyABKAgiNgoRVXBkYXRlSXRlbVJlcXVlc3QSEwoLaXRlbV9udW1iZXIYASABKAMSDAoEbmFtZRgCIAEoCSIgCgRJdGVtEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAky8AIKEEdldFBhcmFtc1NlcnZpY2USWQoHR2V0SXRlbRIeLnRlc3QuZ2V0cGFyYW1zLkdldEl0ZW1SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSIYmrUYFAoQL2l0ZW1zL3tpdGVtX2lkfRABEowBCg5HZXRJdGVtVmVyc2lvbhIlLnRlc3QuZ2V0cGFyYW1zLkdldEl0ZW1WZXJzaW9uUmVxdWVzdBoULnRlc3QuZ2V0cGFyYW1zLkl0ZW0iPZq1GDkKNS9pdGVtcy97aXRlbV9udW1iZXJ9L3ZlcnNpb25zL3t2ZXJzaW9ufS9kcmFmdC97ZHJhZnR9EAESYwoKVXBkYXRlSXRlbRIhLnRlc3QuZ2V0cGFyYW1zLlVwZGF0ZUl0ZW1SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSIcmrUYGAoUL2l0ZW1zL3tpdGVtX251bWJlcn0QAhoNorUYCQoHL2FwaS92MUJPWk1naXRodWIuY29tL1NlYmFzdGllbk1lbGtpL3NlYnVmL2ludGVybmFsL2h0dHBnZW4vdGVzdGRhdGEvZ2VuZXJhdGVkO2dlbmVyYXRlZGIGcHJvdG8z", [file_sebuf_http_annotations]); /** * @generated from message test.getparams.GetItemRequest @@ -40,6 +40,30 @@ export type GetItemRequest = Message<"test.getparams.GetItemRequest"> & { * @generated from field: bool include_archived = 4; */ includeArchived: boolean; + + /** + * Repeated (non-enum) query parameters. On the server these arrive as a + * getAll() string[] and must be coerced to the protobuf-es element type + * (string[] passthrough, number[]/bigint[]/boolean[] mapped). + * + * @generated from field: repeated string tags = 5; + */ + tags: string[]; + + /** + * @generated from field: repeated int32 sizes = 6; + */ + sizes: number[]; + + /** + * @generated from field: repeated int64 ids = 7; + */ + ids: bigint[]; + + /** + * @generated from field: repeated bool flags = 8; + */ + flags: boolean[]; }; /** diff --git a/internal/tsclientgen/testdata/proto/get_params.proto b/internal/tsclientgen/testdata/proto/get_params.proto index a4bf76f7..f0ad5668 100644 --- a/internal/tsclientgen/testdata/proto/get_params.proto +++ b/internal/tsclientgen/testdata/proto/get_params.proto @@ -51,6 +51,13 @@ message GetItemRequest { string query = 2 [(sebuf.http.query) = { name: "q" }]; int32 limit = 3 [(sebuf.http.query) = { name: "limit" }]; bool include_archived = 4 [(sebuf.http.query) = { name: "include_archived" }]; + // Repeated (non-enum) query parameters. On the server these arrive as a + // getAll() string[] and must be coerced to the protobuf-es element type + // (string[] passthrough, number[]/bigint[]/boolean[] mapped). + repeated string tags = 5 [(sebuf.http.query) = { name: "tags" }]; + repeated int32 sizes = 6 [(sebuf.http.query) = { name: "sizes" }]; + repeated int64 ids = 7 [(sebuf.http.query) = { name: "ids" }]; + repeated bool flags = 8 [(sebuf.http.query) = { name: "flags" }]; } message GetItemVersionRequest { diff --git a/internal/tsservergen/generator.go b/internal/tsservergen/generator.go index f1c99888..60052b8a 100644 --- a/internal/tsservergen/generator.go +++ b/internal/tsservergen/generator.go @@ -675,6 +675,31 @@ func esPathParamInitExpr(field *protogen.Field, raw string) string { } } +// esQueryListInitExpr returns the TypeScript expression that coerces a +// `getAll()` string[] into the element type protobuf-es expects for a repeated +// query-param field. Query values arrive as strings, but protobuf-es types a +// `repeated int32` as `number[]`, a repeated 64-bit field as `bigint[]`, and a +// `repeated bool` as `boolean[]`. Enum lists never reach here in es-mode +// (rejected up front by checkNoEnumParamsES); string lists pass through. +func esQueryListInitExpr(field *protogen.Field, getAll string) string { + if field == nil { + return getAll + } + switch field.Desc.Kind() { + case protoreflect.BoolKind: + return getAll + `.map((v) => v === "true")` + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind, + protoreflect.FloatKind, protoreflect.DoubleKind: + return getAll + ".map(Number)" + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return getAll + ".map(BigInt)" + default: + return getAll + } +} + // generatePathParamMerge generates code to merge path params into the request body. // This ensures the handler receives a fully populated request, matching Go generator behavior. func (g *Generator) generatePathParamMerge(p tscommon.Printer, cfg *rpcRouteConfig, _ *protogen.Method) { @@ -864,9 +889,17 @@ func (g *Generator) generateQueryParamField(p tscommon.Printer, qp annotations.Q // Handle repeated fields: use getAll() for multi-value params if qp.Field != nil && qp.Field.Desc.IsList() { - if qp.Field.Desc.Kind() == protoreflect.EnumKind && qp.Field.Enum != nil { + switch { + case qp.Field.Desc.Kind() == protoreflect.EnumKind && qp.Field.Enum != nil: + // Hand-rolled only: enums are string unions here. es-mode rejects enum + // query params before emission (see checkNoEnumParamsES). p(` %s: params.getAll("%s") as %s[],`, jsonName, paramName, g.ctx.RefEnum(qp.Field.Enum)) - } else { + case g.ctx.MessageRuntime == tscommon.MessageRuntimeES: + // protobuf-es element types are strongly typed; coerce the string[] + // from getAll() to number[]/bigint[]/boolean[] (string[] passes through). + getAll := fmt.Sprintf(`params.getAll("%s")`, paramName) + p(` %s: %s,`, jsonName, esQueryListInitExpr(qp.Field, getAll)) + default: p(` %s: params.getAll("%s"),`, jsonName, paramName) } return diff --git a/internal/tsservergen/testdata/golden/es/get_params_pb.ts b/internal/tsservergen/testdata/golden/es/get_params_pb.ts index 1220dc0a..260d418d 100644 --- a/internal/tsservergen/testdata/golden/es/get_params_pb.ts +++ b/internal/tsservergen/testdata/golden/es/get_params_pb.ts @@ -11,7 +11,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file get_params.proto. */ export const file_get_params: GenFile = /*@__PURE__*/ - fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyKHAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZCJMChVHZXRJdGVtVmVyc2lvblJlcXVlc3QSEwoLaXRlbV9udW1iZXIYASABKAMSDwoHdmVyc2lvbhgCIAEoBRINCgVkcmFmdBgDIAEoCCI2ChFVcGRhdGVJdGVtUmVxdWVzdBITCgtpdGVtX251bWJlchgBIAEoAxIMCgRuYW1lGAIgASgJIiAKBEl0ZW0SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCTLwAgoQR2V0UGFyYW1zU2VydmljZRJZCgdHZXRJdGVtEh4udGVzdC5nZXRwYXJhbXMuR2V0SXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhiatRgUChAvaXRlbXMve2l0ZW1faWR9EAESjAEKDkdldEl0ZW1WZXJzaW9uEiUudGVzdC5nZXRwYXJhbXMuR2V0SXRlbVZlcnNpb25SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSI9mrUYOQo1L2l0ZW1zL3tpdGVtX251bWJlcn0vdmVyc2lvbnMve3ZlcnNpb259L2RyYWZ0L3tkcmFmdH0QARJjCgpVcGRhdGVJdGVtEiEudGVzdC5nZXRwYXJhbXMuVXBkYXRlSXRlbVJlcXVlc3QaFC50ZXN0LmdldHBhcmFtcy5JdGVtIhyatRgYChQvaXRlbXMve2l0ZW1fbnVtYmVyfRACGg2itRgJCgcvYXBpL3YxQk9aTWdpdGh1Yi5jb20vU2ViYXN0aWVuTWVsa2kvc2VidWYvaW50ZXJuYWwvaHR0cGdlbi90ZXN0ZGF0YS9nZW5lcmF0ZWQ7Z2VuZXJhdGVkYgZwcm90bzM", [file_sebuf_http_annotations]); + fileDesc("ChBnZXRfcGFyYW1zLnByb3RvEg50ZXN0LmdldHBhcmFtcyLxAQoOR2V0SXRlbVJlcXVlc3QSDwoHaXRlbV9pZBgBIAEoCRIWCgVxdWVyeRgCIAEoCUIHwrUYAwoBcRIaCgVsaW1pdBgDIAEoBUILwrUYBwoFbGltaXQSMAoQaW5jbHVkZV9hcmNoaXZlZBgEIAEoCEIWwrUYEgoQaW5jbHVkZV9hcmNoaXZlZBIYCgR0YWdzGAUgAygJQgrCtRgGCgR0YWdzEhoKBXNpemVzGAYgAygFQgvCtRgHCgVzaXplcxIWCgNpZHMYByADKANCCcK1GAUKA2lkcxIaCgVmbGFncxgIIAMoCEILwrUYBwoFZmxhZ3MiTAoVR2V0SXRlbVZlcnNpb25SZXF1ZXN0EhMKC2l0ZW1fbnVtYmVyGAEgASgDEg8KB3ZlcnNpb24YAiABKAUSDQoFZHJhZnQYAyABKAgiNgoRVXBkYXRlSXRlbVJlcXVlc3QSEwoLaXRlbV9udW1iZXIYASABKAMSDAoEbmFtZRgCIAEoCSIgCgRJdGVtEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAky8AIKEEdldFBhcmFtc1NlcnZpY2USWQoHR2V0SXRlbRIeLnRlc3QuZ2V0cGFyYW1zLkdldEl0ZW1SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSIYmrUYFAoQL2l0ZW1zL3tpdGVtX2lkfRABEowBCg5HZXRJdGVtVmVyc2lvbhIlLnRlc3QuZ2V0cGFyYW1zLkdldEl0ZW1WZXJzaW9uUmVxdWVzdBoULnRlc3QuZ2V0cGFyYW1zLkl0ZW0iPZq1GDkKNS9pdGVtcy97aXRlbV9udW1iZXJ9L3ZlcnNpb25zL3t2ZXJzaW9ufS9kcmFmdC97ZHJhZnR9EAESYwoKVXBkYXRlSXRlbRIhLnRlc3QuZ2V0cGFyYW1zLlVwZGF0ZUl0ZW1SZXF1ZXN0GhQudGVzdC5nZXRwYXJhbXMuSXRlbSIcmrUYGAoUL2l0ZW1zL3tpdGVtX251bWJlcn0QAhoNorUYCQoHL2FwaS92MUJPWk1naXRodWIuY29tL1NlYmFzdGllbk1lbGtpL3NlYnVmL2ludGVybmFsL2h0dHBnZW4vdGVzdGRhdGEvZ2VuZXJhdGVkO2dlbmVyYXRlZGIGcHJvdG8z", [file_sebuf_http_annotations]); /** * @generated from message test.getparams.GetItemRequest @@ -40,6 +40,30 @@ export type GetItemRequest = Message<"test.getparams.GetItemRequest"> & { * @generated from field: bool include_archived = 4; */ includeArchived: boolean; + + /** + * Repeated (non-enum) query parameters. On the server these arrive as a + * getAll() string[] and must be coerced to the protobuf-es element type + * (string[] passthrough, number[]/bigint[]/boolean[] mapped). + * + * @generated from field: repeated string tags = 5; + */ + tags: string[]; + + /** + * @generated from field: repeated int32 sizes = 6; + */ + sizes: number[]; + + /** + * @generated from field: repeated int64 ids = 7; + */ + ids: bigint[]; + + /** + * @generated from field: repeated bool flags = 8; + */ + flags: boolean[]; }; /** diff --git a/internal/tsservergen/testdata/golden/es/get_params_server.ts b/internal/tsservergen/testdata/golden/es/get_params_server.ts index 8f1fe907..9608aa79 100644 --- a/internal/tsservergen/testdata/golden/es/get_params_server.ts +++ b/internal/tsservergen/testdata/golden/es/get_params_server.ts @@ -50,6 +50,10 @@ export function createGetParamsServiceRoutes( query: params.get("q") ?? "", limit: Number(params.get("limit") ?? "0"), includeArchived: params.get("include_archived") === "true", + tags: params.getAll("tags"), + sizes: params.getAll("sizes").map(Number), + ids: params.getAll("ids").map(BigInt), + flags: params.getAll("flags").map((v) => v === "true"), }); if (options?.validateRequest) { const bodyViolations = options.validateRequest("getItem", body); diff --git a/internal/tsservergen/testdata/proto/get_params.proto b/internal/tsservergen/testdata/proto/get_params.proto index a4bf76f7..f0ad5668 100644 --- a/internal/tsservergen/testdata/proto/get_params.proto +++ b/internal/tsservergen/testdata/proto/get_params.proto @@ -51,6 +51,13 @@ message GetItemRequest { string query = 2 [(sebuf.http.query) = { name: "q" }]; int32 limit = 3 [(sebuf.http.query) = { name: "limit" }]; bool include_archived = 4 [(sebuf.http.query) = { name: "include_archived" }]; + // Repeated (non-enum) query parameters. On the server these arrive as a + // getAll() string[] and must be coerced to the protobuf-es element type + // (string[] passthrough, number[]/bigint[]/boolean[] mapped). + repeated string tags = 5 [(sebuf.http.query) = { name: "tags" }]; + repeated int32 sizes = 6 [(sebuf.http.query) = { name: "sizes" }]; + repeated int64 ids = 7 [(sebuf.http.query) = { name: "ids" }]; + repeated bool flags = 8 [(sebuf.http.query) = { name: "flags" }]; } message GetItemVersionRequest {