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 +96 -5
- package/dist/index.d.ts +67 -40
- package/dist/index.js +182 -82
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
type
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
74
|
-
Client<T extends AbstractServiceClass, Impl extends
|
|
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" ?
|
|
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 };
|