better-grpc 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,7 @@ It enables seamless, **bidirectional** communication between a client and a serv
22
22
  - **No `.proto` files:** No need to write `.proto` files or use `protoc` to generate code.
23
23
  - **Simple API:** The API is designed to be simple and intuitive.
24
24
  - **Symmetric Experience:** Call client-side functions from the server with the same syntax as calling server-side functions from the client.
25
+ - **Multi-Client Support:** Target specific clients from the server using client IDs, enabling per-client communication patterns.
25
26
 
26
27
  ## Installation
27
28
 
@@ -146,7 +147,68 @@ for await (const [message] of server.MyService.chat) {
146
147
  }
147
148
  ```
148
149
 
149
- ### 7. Attach typed metadata
150
+ ### 7. Listen for bidi connections on the server
151
+
152
+ The server can use the `.listen()` API to handle incoming bidi stream connections. This is useful for setting up handlers that respond to each client connection:
153
+
154
+ ```typescript
155
+ server.MyService.chat.listen(({ context, messages, send }) => {
156
+ console.log(`New client connected ${context.client.id}`);
157
+
158
+ (async () => {
159
+ for await (const [message] of messages) {
160
+ console.log('Received:', message);
161
+ await send(`Echo: ${message}`);
162
+ }
163
+ })();
164
+ });
165
+ ```
166
+
167
+ The `listen` handler receives:
168
+ - `context`: A promise that resolves to the connection context (including metadata if defined)
169
+ - `messages`: An async generator yielding incoming messages from the client
170
+ - `send`: A function to send messages back to the client
171
+
172
+ ### 8. Target specific clients
173
+
174
+ When multiple clients are connected, the server can target a specific client using its client ID. Each client is automatically assigned a unique ID.
175
+
176
+ **Getting the client ID on the client side:**
177
+
178
+ ```typescript
179
+ const client = await createGrpcClient('localhost:50051', myClientImpl);
180
+
181
+ // Access the client's unique ID
182
+ console.log(client.clientID); // e.g., 'abc123-def456-...'
183
+ ```
184
+
185
+ **Getting the client ID on the server side:**
186
+
187
+ In server handlers, the client ID is available via `context.client.id`:
188
+
189
+ ```typescript
190
+ const GreeterServerImpl = GreeterService.Server({
191
+ greet: (name) => async (context) => {
192
+ console.log('Client ID:', context.client.id);
193
+ return `Hello, ${name}!`;
194
+ },
195
+ });
196
+ ```
197
+
198
+ **Targeting a specific client from the server:**
199
+
200
+ ```typescript
201
+ // Call a specific client by ID
202
+ const clientId = 'some-client-id';
203
+ await server.MyService(clientId).log('Message for specific client');
204
+
205
+ // For bidi streams, targeting a specific client
206
+ await server.MyService(clientId).chat('hello to specific client');
207
+ ```
208
+
209
+ By default, server calls target the first connected client. When you need to communicate with a specific client (e.g., in a multi-client scenario), use the client ID selector.
210
+
211
+ ### 9. Attach typed metadata
150
212
 
151
213
  Define metadata requirements with [Zod](https://github.com/colinhacks/zod) schemas, and `better-grpc` will automatically type the context on both sides and marshal the payload into gRPC metadata.
152
214
 
@@ -165,11 +227,11 @@ abstract class GreeterService extends Service('GreeterService') {
165
227
  }
166
228
  ```
167
229
 
168
- Server implementations receive the typed metadata as the first argument:
230
+ Server implementations can optionally return a context-aware function. The outer function receives the request args, and the returned function receives the typed context:
169
231
 
170
232
  ```typescript
171
233
  const GreeterServerImpl = GreeterService.Server({
172
- async greet(context, name) {
234
+ greet: (name) => async (context) => {
173
235
  console.log('Request', context.metadata.requestId);
174
236
  return `Hello, ${name}!`;
175
237
  },
@@ -215,7 +277,7 @@ A factory function that creates an abstract service class.
215
277
 
216
278
  - `server<T>()`
217
279
 
218
- Defines a server-side unary function signature. `T` should be a function type. Call the returned descriptor with `({ metadata: z.object({...}) })` to require typed metadata for that RPC. Client code then calls `client.MyService.fn(...args).withMeta({...})`, and server handlers receive the context object as the first argument.
280
+ Defines a server-side unary function signature. `T` should be a function type. Call the returned descriptor with `({ metadata: z.object({...}) })` to require typed metadata for that RPC. Client code then calls `client.MyService.fn(...args).withMeta({...})`, and server handlers can return a function to receive the context (or just return a value if they don't need it).
219
281
 
220
282
  - `client<T>()`
221
283
 
@@ -227,7 +289,7 @@ Defines a bidirectional stream signature. `T` should be a function type that ret
227
289
 
228
290
  - `createGrpcServer(port: number, ...services: ServiceImpl[])`
229
291
 
230
- Creates and starts a gRPC server.
292
+ Creates and starts a gRPC server. Returns service callables that can be invoked directly or with a client ID selector: `server.MyService.method()` or `server.MyService(clientId).method()`.
231
293
 
232
294
  - `createGrpcClient(address: string, ...services: ServiceImpl[])`
233
295
 
@@ -237,6 +299,35 @@ Creates and starts a gRPC client using `DEFAULT_OPTIONS`.
237
299
 
238
300
  Creates and starts a gRPC client with custom gRPC channel options. `DEFAULT_OPTIONS` is exported for easy overrides.
239
301
 
302
+ ### Server-side bidi listen
303
+
304
+ For bidi streams, the server exposes a `.listen()` method to handle incoming connections:
305
+
306
+ ```typescript
307
+ server.MyService.bidiFn.listen((connection) => {
308
+ // connection.context: Promise<Context> - resolves to typed context/metadata
309
+ // connection.messages: AsyncGenerator - incoming messages from client
310
+ // connection.send: Function - send messages to the client
311
+ });
312
+ ```
313
+
314
+ ## Deployment
315
+
316
+ If you deploy behind Traefik (including Dokploy), make sure the **entrypoint** timeouts allow long-lived HTTP/2 streams. Otherwise, bidi streams can be cancelled around the default timeout window.
317
+
318
+ This is a static Traefik setting (not the dynamic `http:` config). Add this to your Traefik config and reload:
319
+
320
+ ```yaml
321
+ entryPoints:
322
+ websecure:
323
+ address: :443
324
+ transport:
325
+ respondingTimeouts:
326
+ readTimeout: 0s
327
+ writeTimeout: 0s
328
+ idleTimeout: 0s
329
+ ```
330
+
240
331
  ## Benchmarks
241
332
 
242
333
  ### Simple "Hello World"
package/dist/index.d.ts CHANGED
@@ -1,39 +1,26 @@
1
- import { z } from 'zod';
1
+ import z$1, { z } from 'zod';
2
2
  import { ChannelOptions } from 'nice-grpc';
3
3
 
4
4
  type Context<Meta extends z.ZodObject<any> | undefined> = {
5
5
  metadata: Meta extends z.ZodObject<any> ? z.infer<Meta> : undefined;
6
+ client: {
7
+ id: string;
8
+ };
6
9
  };
7
- type PrependContext<C extends Context<any>, Args extends any[]> = [C, ...Args];
8
- type DefaultContext = Context<undefined>;
9
- type HasMeta<C extends Context<any>> = C extends Context<infer M> ? (M extends undefined ? false : true) : false;
10
- type ExtractMeta<C extends Context<any>> = C extends Context<infer M> ? M : undefined;
11
- type ContextRequiredFn<fn extends (...args: any[]) => any, C extends Context<any>, RequireMeta extends boolean = HasMeta<C>> = RequireMeta extends true ? (...args: Parameters<fn>) => {
12
- withMeta<M extends ExtractMeta<C>>(metadata: z.infer<M>): ContextRequiredFn<fn, C, false>;
13
- } : ReturnType<fn>;
10
+ type AnyContext = Context<any>;
14
11
 
15
12
  declare const ScopeTag: unique symbol;
16
- type AnyFn<R> = (...args: any[]) => R;
17
- type BidiType<fn extends AnyFn<any>, C extends Context<any>, type extends "server" | "client"> = fn & AsyncGenerator<Parameters<fn>, void, unknown> & BidiContextType<C, type>;
18
- type BidiContextType<C extends Context<any>, type extends "server" | "client"> = type extends "client" ? {
19
- context: Promise<C>;
20
- } : {
21
- context(context: C): Promise<void>;
22
- };
23
- type serverSignature<fn extends AnyFn<any>, C extends Context<any>> = (C extends DefaultContext ? <Meta extends z.ZodObject<any> | undefined>(context: {
24
- metadata?: Meta;
25
- }) => serverSignature<fn, Context<Meta>> : fn) & {
26
- [ScopeTag]: "server";
27
- };
28
- type clientSignature<fn extends AnyFn<any>> = fn & {
29
- [ScopeTag]: "client";
30
- };
31
- type bidiSignature<fn extends AnyFn<void>, C extends Context<any>> = (C extends DefaultContext ? <Meta extends z.ZodObject<any> | undefined, Ack extends boolean = false>(context: {
32
- metadata?: Meta;
33
- ack?: Ack;
34
- }) => bidiSignature<fn, Context<Meta>> : fn) & {
35
- [ScopeTag]: "bidi";
13
+ declare const FunctionTag: unique symbol;
14
+ declare const ContextTag: unique symbol;
15
+ type BaseSignature<type extends "server" | "client" | "bidi", fn extends (...args: any[]) => any, C extends AnyContext | undefined> = {
16
+ [ScopeTag]: type;
17
+ [FunctionTag]: fn;
18
+ [ContextTag]: type extends "server" ? (C extends undefined ? Context<undefined> : C) : C;
36
19
  };
20
+ type AnyBaseSignature = BaseSignature<any, any, any>;
21
+ type ExtractFn<S extends AnyBaseSignature> = (...args: Parameters<S[typeof FunctionTag]>) => Promise<ReturnType<S[typeof FunctionTag]>>;
22
+ type ValidReturnType<fn extends (...args: any[]) => any> = ReturnType<fn> extends Function | Promise<any> ? never : ReturnType<fn>;
23
+ type ExtractImplFn<S extends AnyBaseSignature> = S[typeof ScopeTag] extends "server" ? ExtractFn<S> | ((...args: Parameters<S[typeof FunctionTag]>) => (context: S[typeof ContextTag]) => Promise<ReturnType<S[typeof FunctionTag]>>) : S[typeof ScopeTag] extends "client" ? ExtractFn<S> : S[typeof ScopeTag] extends "bidi" ? undefined : never;
37
24
  type RpcMethodDescriptor = {
38
25
  serviceType: "server" | "client" | "bidi";
39
26
  methodType: "unary" | "bidi";
@@ -42,16 +29,52 @@ type RpcMethodDescriptor = {
42
29
  ack: boolean;
43
30
  };
44
31
  };
45
- declare function server<fn extends AnyFn<any>>(): serverSignature<(...args: Parameters<fn>) => Promise<ReturnType<fn>>, DefaultContext>;
46
- declare function client<fn extends AnyFn<any>>(): clientSignature<(...args: Parameters<fn>) => Promise<ReturnType<fn>>>;
47
- declare function bidi<fn extends AnyFn<void>>(..._: ReturnType<fn> extends void ? [] : ["Return type must be void"]): bidiSignature<(...args: Parameters<fn>) => Promise<void>, DefaultContext>;
48
- type Unwrap<T, type extends "server" | "client", ContextChain extends boolean = false> = T extends serverSignature<infer F, infer C> ? C extends DefaultContext ? F : ContextChain extends true ? ContextRequiredFn<F, C> : (...args: PrependContext<C, Parameters<F>>) => ReturnType<F> : T extends clientSignature<infer F> ? F : T extends bidiSignature<infer F, infer C> ? BidiType<F, C, type> : never;
49
- type ServerFn<T, IncludeBidi extends boolean = true, ContextChain extends boolean = false> = {
50
- [K in keyof T as T[K] extends serverSignature<AnyFn<any>, any> | (IncludeBidi extends true ? bidiSignature<AnyFn<void>, any> : never) ? K : never]: Unwrap<T[K], "server", ContextChain>;
32
+
33
+ type BidiSignature<fn extends (...args: any[]) => any, C extends AnyContext | undefined> = BaseSignature<"bidi", fn, C> & (C extends undefined ? <Meta extends z$1.ZodObject<any>>(context: {
34
+ metadata?: Meta;
35
+ ack?: boolean;
36
+ }) => BidiSignature<fn, Context<Meta>> : {});
37
+ declare function bidi<fn extends (...args: any[]) => ValidReturnType<fn>>(): BidiSignature<fn, undefined>;
38
+
39
+ type ClientSignature<fn extends (...args: any[]) => any, C extends AnyContext | undefined> = BaseSignature<"client", fn, C>;
40
+ declare function client<fn extends (...args: any[]) => ValidReturnType<fn>>(): ClientSignature<fn, undefined>;
41
+ type ClientImpls<T> = {
42
+ [K in keyof T as T[K] extends BaseSignature<"client", any, any> ? K : never]: T[K] extends AnyBaseSignature ? ExtractImplFn<T[K]> : never;
43
+ };
44
+ type ClientCallable<T, WithListen extends boolean = true> = {
45
+ [K in keyof T as T[K] extends BaseSignature<"client", any, any> | BaseSignature<"bidi", any, any> ? K : never]: T[K] extends BaseSignature<"client", any, any> ? ExtractFn<T[K]> : T[K] extends BaseSignature<"bidi", any, infer C> ? WithListen extends true ? BidiCallable$1<T[K], C> : BidiCallableWithoutListen<T[K], C> : never;
51
46
  };
52
- type ClientFn<T, IncludeBidi extends boolean = true> = {
53
- [K in keyof T as T[K] extends clientSignature<AnyFn<any>> | (IncludeBidi extends true ? bidiSignature<AnyFn<void>, any> : never) ? K : never]: Unwrap<T[K], "client">;
47
+ type BidiContext<C extends AnyContext | undefined> = C extends undefined ? Promise<Context<undefined>> : Promise<C>;
48
+ type BidiCallableBase<S extends BaseSignature<"bidi", any, any>, C extends AnyContext | undefined> = ExtractFn<S> & {
49
+ context: BidiContext<C>;
50
+ } & AsyncGenerator<Parameters<ExtractFn<S>>, void, unknown>;
51
+ type BidiCallable$1<S extends BaseSignature<"bidi", any, any>, C extends AnyContext | undefined> = BidiCallableBase<S, C> & {
52
+ listen(handler: (connection: {
53
+ context: BidiContext<C>;
54
+ messages: AsyncGenerator<Parameters<ExtractFn<S>>, void, unknown>;
55
+ send: ExtractFn<S>;
56
+ }) => void): void;
54
57
  };
58
+ type BidiCallableWithoutListen<S extends BaseSignature<"bidi", any, any>, C extends AnyContext | undefined> = BidiCallableBase<S, C>;
59
+
60
+ type ServerSignature<fn extends (...args: any[]) => any, C extends AnyContext | undefined> = BaseSignature<"server", fn, C> & (C extends undefined ? <Meta extends z$1.ZodObject<any>>(context: {
61
+ metadata: Meta;
62
+ }) => ServerSignature<fn, Context<Meta>> : {});
63
+ declare function server<fn extends (...args: any[]) => ValidReturnType<fn>>(): ServerSignature<fn, undefined>;
64
+ type ServerImpls<T> = {
65
+ [K in keyof T as T[K] extends BaseSignature<"server", any, any> ? K : never]: T[K] extends AnyBaseSignature ? ExtractImplFn<T[K]> : never;
66
+ };
67
+ type ServerCallable<T> = {
68
+ [K in keyof T as T[K] extends BaseSignature<"server", any, any> | BaseSignature<"bidi", any, any> ? K : never]: T[K] extends BaseSignature<"server", any, infer C> ? (...args: Parameters<ExtractFn<T[K]>>) => CallableChain<ExtractFn<T[K]>, C> : T[K] extends BaseSignature<"bidi", any, infer C> ? BidiCallable<T[K], C> : never;
69
+ };
70
+ type CallableChain<fn extends (...args: any[]) => any, C extends AnyContext | undefined> = C extends Context<infer Meta> ? Meta extends z$1.ZodObject<any> ? {
71
+ withMeta(meta: z$1.infer<Meta>): ReturnType<fn>;
72
+ } : ReturnType<fn> : ReturnType<fn>;
73
+ type BidiCallable<S extends BaseSignature<"bidi", any, any>, C extends AnyContext | undefined> = (C extends Context<infer Meta> ? Meta extends z$1.ZodObject<any> ? {
74
+ context(context: {
75
+ metadata: z$1.infer<Meta>;
76
+ }): Promise<void>;
77
+ } & ExtractFn<S> : ExtractFn<S> : ExtractFn<S>) & AsyncGenerator<Parameters<ExtractFn<S>>, void, unknown>;
55
78
 
56
79
  declare const ServiceNameTag: unique symbol;
57
80
  interface ServiceInstance<N extends string = string> {
@@ -70,22 +93,26 @@ declare function Service<N extends string>(name: N): (abstract new () => {
70
93
  readonly [ServiceNameTag]: N;
71
94
  }) & {
72
95
  serviceName: N;
73
- Server<T extends AbstractServiceClass, Impl extends ServerFn<InstanceType<T>, false>>(this: T, implementation: Impl): ServiceImpl<T, "server">;
74
- Client<T extends AbstractServiceClass, Impl extends ClientFn<InstanceType<T>, false>>(this: T, implementation: Impl): ServiceImpl<T, "client">;
96
+ Server<T extends AbstractServiceClass, Impl extends ServerImpls<InstanceType<T>>>(this: T, implementation: Impl): ServiceImpl<T, "server">;
97
+ Client<T extends AbstractServiceClass, Impl extends ClientImpls<InstanceType<T>>>(this: T, implementation: Impl): ServiceImpl<T, "client">;
75
98
  };
76
99
  type ServiceNameOf<T extends ServiceImpl<any, any>> = T extends ServiceImpl<infer ServiceClass, any> ? InstanceType<ServiceClass>[typeof ServiceNameTag] : never;
77
- type ServiceCallable<T> = T extends ServiceImpl<infer S, infer Mode> ? Mode extends "server" ? ClientFn<InstanceType<S>> : ServerFn<InstanceType<S>, true, true> : never;
100
+ type ServiceCallable<T, WithListen extends boolean = true> = T extends ServiceImpl<infer S, infer Mode> ? Mode extends "server" ? ClientCallable<InstanceType<S>, WithListen> : ServerCallable<InstanceType<S>> : never;
78
101
 
79
102
  declare const DEFAULT_OPTIONS: ChannelOptions;
80
103
  declare function createGrpcClient<T extends ServiceImpl<any, "client">[]>(address: string, ...serviceImpls: T): Promise<{
81
104
  [I in T[number] as ServiceNameOf<I>]: ServiceCallable<I>;
105
+ } & {
106
+ clientID: string;
82
107
  }>;
83
108
  declare function createGrpcClient<T extends ServiceImpl<any, "client">[]>(address: string, grpcOptions: ChannelOptions, ...serviceImpls: T): Promise<{
84
109
  [I in T[number] as ServiceNameOf<I>]: ServiceCallable<I>;
110
+ } & {
111
+ clientID: string;
85
112
  }>;
86
113
 
87
114
  declare function createGrpcServer<T extends ServiceImpl<any, "server">[]>(port: number, ...serviceImpls: T): Promise<{
88
- [I in T[number] as ServiceNameOf<I>]: ServiceCallable<I>;
115
+ [I in T[number] as ServiceNameOf<I>]: ServiceCallable<I> & ((clientId: string) => ServiceCallable<I, false>);
89
116
  }>;
90
117
 
91
118
  export { DEFAULT_OPTIONS, Service, bidi, client, createGrpcClient, createGrpcServer, server };