Skip to content

Commit fa45cf4

Browse files
feat(ts): emit protobuf-es client as standalone tree-shakeable functions (#242)
Each RPC as top-level export async function instead of class method. Shared client.ts module with RequestOptions interface. Service-specific RequestOptions extends base when typed headers are declared. const vs let optimization for non-mutated path variables. Docs bug fix: apiKey header snippet was sending literal "apiKey" key instead of "X-API-Key". Original work from PR #211 by @hishamank. Co-authored-by: Hicham <53556927+hishamank@users.noreply.github.com>
1 parent 6ba2ebf commit fa45cf4

37 files changed

Lines changed: 821 additions & 317 deletions

docs/client-generation.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,59 @@ client/server sees protobuf-es's types and conventions:
777777
optional fields.
778778
- **int64 as `bigint`.** 64-bit integer fields (`int64`, `uint64`, `sint64`,
779779
`fixed64`, `sfixed64`) are represented as `bigint`, following protobuf-es.
780+
- **Standalone functions, not a client class.** Each RPC is emitted as a
781+
top-level `export async function` that takes its config per call as a required
782+
options argument (`baseURL` required; `fetch`, `headers`, `signal` optional).
783+
The default plain-interface mode still emits a `class` you `new` and configure
784+
once.
785+
786+
```ts
787+
import { getUser } from "./gen/user_service_client.js";
788+
789+
const opts = { baseURL: "https://api.example.com" };
790+
const user = await getUser({ id: "123" }, opts);
791+
```
792+
793+
Because every RPC is an independent top-level function, bundlers **tree-shake**
794+
the ones you don't import — using a single method doesn't pull the whole
795+
service into your bundle. There is no client object to construct or pass
796+
around; hold the options object (plain data) if you want to reuse config.
797+
798+
The options type is a **shared `RequestOptions`** (emitted once in the shared
799+
`client.ts`), so it is not duplicated per service. A service that declares
800+
typed headers instead gets a `{Service}RequestOptions` that **`extends
801+
RequestOptions`** and adds only its typed header properties:
802+
803+
```ts
804+
// client.ts (shared)
805+
export interface RequestOptions { baseURL: string; fetch?: typeof fetch; headers?: Record<string, string>; signal?: AbortSignal; }
806+
// token_service_client.ts (declares X-API-Key + X-Request-ID headers)
807+
export interface TokenServiceRequestOptions extends RequestOptions { apiKey?: string; requestId?: string; }
808+
```
809+
810+
Note that a typed header is its **own top-level property** (`apiKey`), not an
811+
entry in `headers` — the generated function maps it to the real header name
812+
(`X-API-Key`) for you. `headers` stays available as an escape hatch for headers
813+
the proto doesn't declare.
814+
815+
Requiring `baseURL` on every call is the cost of statelessness, but it does
816+
not have to be repeated: declare the options object once, typed as the
817+
service's own options type so its typed header properties are checked, and
818+
reuse the reference. It is plain data, so spreading it per call layers on
819+
per-call extras (`signal`, a typed header property) without mutating the shared
820+
config. Spreading replaces properties rather than deep-merging them, so an
821+
`opts` that sets `headers` needs that record merged explicitly
822+
(`headers: { ...opts.headers, "X-Trace-Id": id }`); typed header properties are
823+
top-level and so compose cleanly:
824+
825+
```ts
826+
import { issue, type TokenServiceRequestOptions } from "./gen/token_service_client.js";
827+
828+
const opts: TokenServiceRequestOptions = { baseURL: "https://api.example.com", apiKey };
829+
830+
await issue({ subject: "user-1" }, opts);
831+
await issue({ subject: "user-2" }, { ...opts, requestId: crypto.randomUUID() });
832+
```
780833

781834
### Server-streaming (SSE)
782835

@@ -840,7 +893,7 @@ it in the registry. Proto errors are protobuf-es messages, so they carry a
840893
`$typeName` discriminant:
841894

842895
```ts
843-
const r = await client.getAccount({ id });
896+
const r = await getAccount({ id }, { baseURL: "https://api.example.com" });
844897
if (!r.ok) {
845898
const e = r.error;
846899
if (e instanceof ValidationError) {

0 commit comments

Comments
 (0)