@forwardimpact/librpc 0.1.111 → 0.1.112

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
@@ -13,11 +13,24 @@ transport.
13
13
  import { Server, Client, createClient, createTracer } from '@forwardimpact/librpc';
14
14
  ```
15
15
 
16
+ ## Unary deadlines
17
+
18
+ Every unary call carries one absolute gRPC deadline. The deadline spans
19
+ all retry attempts. The default is 60s. That default matches the slowest
20
+ unary in practice (embedding model inference). A hung connection fails
21
+ with `DEADLINE_EXCEEDED`. Retryable errors (UNAVAILABLE and friends)
22
+ cycle only until the call spends its budget. The first attempt past the
23
+ deadline fails immediately. `DEADLINE_EXCEEDED` gets no retry. Override
24
+ the deadline per service with the `deadline` config key (milliseconds)
25
+ in the service's config block. Once the key exists there, the
26
+ `SERVICE_{NAME}_DEADLINE` env var overrides it. Streaming calls are
27
+ exempt. Keepalive bounds them, and long-lived streams are legitimate.
28
+
16
29
  ## Documentation
17
30
 
18
31
  - [Ship a Service Endpoint](https://www.forwardimpact.team/docs/libraries/typed-contracts/ship-endpoint/index.md)
19
32
  — ship and consume a gRPC service with typed contracts, authentication,
20
- retries, and health checks; `fit-unary` is the command-line client for it.
33
+ retries, and health checks. `fit-unary` is the command-line client for it.
21
34
  - [Keep Types Synced with Proto Definitions](https://www.forwardimpact.team/docs/libraries/typed-contracts/index.md)
22
- — the full workflow for defining proto contracts and generating typed base
35
+ — the full workflow to define proto contracts and generate typed base
23
36
  classes and clients.
package/bin/fit-unary.js CHANGED
@@ -23,13 +23,13 @@ const definition = {
23
23
  title: "Ship a Service Endpoint",
24
24
  url: "https://www.forwardimpact.team/docs/libraries/typed-contracts/ship-endpoint/index.md",
25
25
  description:
26
- "Ship and consume a gRPC service with typed contracts, authentication, retries, and health checks; fit-unary is the command-line client for it.",
26
+ "Ship and consume a gRPC service with typed contracts, authentication, retries, and health checks. fit-unary is the command-line client for it.",
27
27
  },
28
28
  {
29
29
  title: "Keep Types Synced with Proto Definitions",
30
30
  url: "https://www.forwardimpact.team/docs/libraries/typed-contracts/index.md",
31
31
  description:
32
- "The full workflow for defining proto contracts and generating typed base classes and clients.",
32
+ "The full workflow to define proto contracts and generate typed base classes and clients.",
33
33
  },
34
34
  ],
35
35
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/librpc",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "description": "gRPC server and client framework — ship service endpoints without reimplementing transport.",
5
5
  "keywords": [
6
6
  "grpc",
package/src/auth.js CHANGED
@@ -12,7 +12,7 @@ export class HmacAuth {
12
12
 
13
13
  /**
14
14
  * Creates a new HMAC authenticator instance
15
- * @param {string} secret - Shared secret key for HMAC generation (minimum 32 characters)
15
+ * @param {string} secret - Shared secret key to generate the HMAC (minimum 32 characters)
16
16
  * @param {number} tokenLifetimeSeconds - Token lifetime in seconds (default: 60)
17
17
  * @param {object} [options] - Optional collaborators
18
18
  * @param {() => number} [options.now] - Injectable clock (default: Date.now)
@@ -36,7 +36,7 @@ export class HmacAuth {
36
36
 
37
37
  /**
38
38
  * Generates an HMAC token for the specified service
39
- * @param {string} serviceId - Identifier of the service requesting authentication
39
+ * @param {string} serviceId - Identifier of the service that requests authentication
40
40
  * @returns {string} Base64 encoded HMAC token
41
41
  * @throws {Error} When serviceId is invalid
42
42
  */
@@ -59,7 +59,7 @@ export class HmacAuth {
59
59
  /**
60
60
  * Verifies an HMAC token and extracts service information
61
61
  * @param {string} token - Base64 encoded HMAC token to verify
62
- * @returns {object} Verification result containing serviceId and isValid
62
+ * @returns {object} Verification result with serviceId and isValid
63
63
  * @throws {Error} When token format is invalid
64
64
  */
65
65
  verifyToken(token) {
@@ -95,7 +95,7 @@ export class HmacAuth {
95
95
  };
96
96
  }
97
97
 
98
- // Check token expiration
98
+ // Check whether the token expired
99
99
  const now = this.#now();
100
100
  if (now - timestamp > this.#tokenLifetimeMs) {
101
101
  return {
@@ -152,7 +152,8 @@ export class HmacAuth {
152
152
 
153
153
  /**
154
154
  * gRPC interceptor for HMAC-based service authentication
155
- * Handles automatic token attachment for outgoing requests and validation for incoming requests
155
+ * Adds a token to each outgoing request automatically and validates each
156
+ * incoming request
156
157
  */
157
158
  export class Interceptor {
158
159
  #authenticator;
@@ -232,14 +233,14 @@ export class Interceptor {
232
233
  };
233
234
  }
234
235
 
235
- // Add service ID to call context for potential use by handlers
236
+ // Add the service ID to the call context so handlers can use it
236
237
  call.serviceId = verification.serviceId;
237
238
  };
238
239
  }
239
240
 
240
241
  /**
241
242
  * Validates an incoming gRPC call's authentication
242
- * This is a helper method for manual authentication validation
243
+ * This helper method validates the authentication manually
243
244
  * @param {object} call - gRPC call object
244
245
  * @returns {object} Verification result with isValid, serviceId, and error properties
245
246
  */
package/src/base.js CHANGED
@@ -6,7 +6,7 @@ import { Interceptor, HmacAuth } from "./auth.js";
6
6
  import { definitions } from "./generated/definitions/exports.js";
7
7
 
8
8
  /**
9
- * Capitalize first letter of a string
9
+ * Capitalize the first letter of a string
10
10
  * @param {string} str - String to capitalize
11
11
  * @returns {string} Capitalized string
12
12
  */
@@ -16,18 +16,18 @@ export function capitalizeFirstLetter(str) {
16
16
 
17
17
  /**
18
18
  * Default grpc factory that creates gRPC dependencies
19
- * @returns {object} Object containing grpc
19
+ * @returns {object} Object with grpc
20
20
  */
21
21
  export function createGrpc() {
22
22
  return { grpc };
23
23
  }
24
24
 
25
25
  /**
26
- * Default auth factory that creates an authentication interceptor. Reads
27
- * `SERVICE_SECRET` from the injected `runtime.proc.env` rather than
28
- * constructing its own process collaborator — the runtime is threaded from the
29
- * entry point through `Server`/`Client` (no leaf-collaborator construction in
30
- * src, Success Criterion 9).
26
+ * Default auth factory that creates an authentication interceptor. It reads
27
+ * `SERVICE_SECRET` from the injected `runtime.proc.env`. It does not construct
28
+ * its own process collaborator. The entry point threads the runtime through
29
+ * `Server`/`Client` (no leaf-collaborator construction in src, Success
30
+ * Criterion 9).
31
31
  * @param {string} serviceName - Name of the service for the interceptor
32
32
  * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime - Injected runtime bag
33
33
  * @returns {Interceptor} Configured interceptor instance
@@ -91,8 +91,8 @@ export class Rpc {
91
91
  const { grpc } = grpcFn();
92
92
  this.#grpc = grpc;
93
93
 
94
- // Setup authentication (the default factory reads SERVICE_SECRET off the
95
- // injected runtime; a mock authFn ignores it)
94
+ // Set up authentication. The default factory reads SERVICE_SECRET off the
95
+ // injected runtime. A mock authFn ignores it.
96
96
  this.#auth = authFn(this.config.name, runtime);
97
97
 
98
98
  // Create observer with logger and tracer
@@ -124,7 +124,7 @@ export class Rpc {
124
124
  tracer = () => this.#observer.tracer();
125
125
 
126
126
  /**
127
- * Get pre-compiled service definition
127
+ * Get the pre-compiled service definition
128
128
  * @param {string} serviceName - Service name (e.g., "Agent", "Vector")
129
129
  * @returns {object} Pre-compiled service definition
130
130
  */
package/src/client.js CHANGED
@@ -9,12 +9,22 @@ import {
9
9
  capitalizeFirstLetter,
10
10
  } from "./base.js";
11
11
 
12
+ // No unary call waits forever. Every call carries one absolute gRPC
13
+ // deadline that spans all retry attempts. So a hung connection fails with
14
+ // DEADLINE_EXCEEDED, and so does a retryable-error grind (exponential
15
+ // backoff on UNAVAILABLE). Neither one blocks the caller forever. The
16
+ // default fits the slowest unary in practice (embedding model inference).
17
+ // Override it per service with the `deadline` config key.
18
+ const DEFAULT_UNARY_DEADLINE_MS = 60_000;
19
+
12
20
  /**
13
- * Creates a gRPC client with consistent API using pre-compiled definitions
21
+ * Creates a gRPC client with a consistent API from pre-compiled definitions
14
22
  */
15
23
  export class Client extends Rpc {
16
24
  #client;
17
25
  #retry;
26
+ #clock;
27
+ #deadlineMs;
18
28
 
19
29
  /**
20
30
  * Creates a new Client instance
@@ -25,7 +35,7 @@ export class Client extends Rpc {
25
35
  * @param {(serviceName: string, logger: object, tracer: object) => object} observerFn - Observer factory
26
36
  * @param {() => {grpc: object}} grpcFn - gRPC factory
27
37
  * @param {(serviceName: string, runtime: object) => object} authFn - Auth factory
28
- * @param {import("@forwardimpact/libutil").Retry} [retry] - Optional retry instance for handling transient errors
38
+ * @param {import("@forwardimpact/libutil").Retry} [retry] - Optional retry instance that handles transient errors
29
39
  */
30
40
  constructor(
31
41
  config,
@@ -39,18 +49,20 @@ export class Client extends Rpc {
39
49
  ) {
40
50
  super(config, runtime, logger, tracer, observerFn, grpcFn, authFn);
41
51
  this.#retry = retry || createRetry({ retries: 10, delay: 1000 });
52
+ this.#clock = runtime.clock;
53
+ this.#deadlineMs = Number(config.deadline) || DEFAULT_UNARY_DEADLINE_MS;
42
54
  this.#setupClient();
43
55
  }
44
56
 
45
57
  /**
46
- * Sets up the gRPC client using pre-compiled definition
58
+ * Sets up the gRPC client from the pre-compiled definition
47
59
  * @private
48
60
  */
49
61
  #setupClient() {
50
62
  const serviceName = capitalizeFirstLetter(this.config.name);
51
63
  const serviceDefinition = this.getServiceDefinition(serviceName);
52
64
 
53
- // In case default host is used, resort to a well-known service name
65
+ // If the config uses the default host, resort to a well-known service name
54
66
  const host =
55
67
  this.config.host === "0.0.0.0"
56
68
  ? `${this.config.name}.guide.local`
@@ -61,7 +73,7 @@ export class Client extends Rpc {
61
73
  interceptors: [this.auth().createClientInterceptor()],
62
74
  };
63
75
 
64
- // Configure client with keepalive for long-running streams
76
+ // Configure the client with keepalive for long-running streams
65
77
  // https://github.com/grpc/grpc-node/blob/master/doc/keepalive.md
66
78
  const channelOptions = {
67
79
  "grpc.keepalive_time_ms": 30000, // Send keepalive ping every 30 seconds
@@ -72,7 +84,7 @@ export class Client extends Rpc {
72
84
  };
73
85
  const clientCredentials = this.grpc().credentials.createInsecure();
74
86
 
75
- // Create client using pre-compiled service definition
87
+ // Create the client from the pre-compiled service definition
76
88
  const ClientConstructor = this.grpc().makeGenericClientConstructor(
77
89
  serviceDefinition,
78
90
  serviceName,
@@ -81,6 +93,15 @@ export class Client extends Rpc {
81
93
  this.#client = new ClientConstructor(uri, clientCredentials, options);
82
94
  }
83
95
 
96
+ /**
97
+ * Close the underlying gRPC channel. This releases its sockets and timers
98
+ * so short-lived processes (CLIs, tests) can exit promptly.
99
+ * @returns {void}
100
+ */
101
+ close() {
102
+ this.#client.close();
103
+ }
104
+
84
105
  /**
85
106
  * Call a gRPC method with automatic CLIENT span tracing and observability.
86
107
  * Supports unary calls.
@@ -199,7 +220,12 @@ export class Client extends Rpc {
199
220
  }
200
221
 
201
222
  /**
202
- * Internal unary call handler with retry logic
223
+ * Internal unary call handler with retry logic. The handler computes the
224
+ * absolute deadline once and shares it with every retry attempt. So
225
+ * retryable errors repeat only until the call spends its budget. The
226
+ * first attempt past the deadline fails at once with DEADLINE_EXCEEDED,
227
+ * which is deliberately not retryable. Streaming calls are exempt.
228
+ * Keepalive bounds them, and long-lived streams are legitimate.
203
229
  * @param {string} methodName - The name of the method
204
230
  * @param {object} request - Request object
205
231
  * @param {object} metadata - gRPC Metadata instance
@@ -207,15 +233,21 @@ export class Client extends Rpc {
207
233
  * @private
208
234
  */
209
235
  async #performUnaryCall(methodName, request, metadata) {
236
+ const options = { deadline: this.#clock.now() + this.#deadlineMs };
210
237
  return await this.#retry.execute(() => {
211
238
  return new Promise((resolve, reject) => {
212
- this.#client[methodName](request, metadata, (error, response) => {
213
- if (error) {
214
- reject(error);
215
- } else {
216
- resolve(response);
217
- }
218
- });
239
+ this.#client[methodName](
240
+ request,
241
+ metadata,
242
+ options,
243
+ (error, response) => {
244
+ if (error) {
245
+ reject(error);
246
+ } else {
247
+ resolve(response);
248
+ }
249
+ },
250
+ );
219
251
  });
220
252
  });
221
253
  }
package/src/health.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * gRPC Health Check protocol (grpc.health.v1.Health/Check)
3
3
  *
4
- * Manual service definition — no .proto file or proto-loader needed.
5
- * Messages use raw protobuf wire encoding (trivial: one string field,
6
- * one enum/varint field).
4
+ * This is a manual service definition. It needs no .proto file and no
5
+ * proto-loader. Messages use raw protobuf wire encoding. The encoding is
6
+ * trivial: one string field and one enum/varint field.
7
7
  */
8
8
 
9
- /** Serving status enum matching grpc.health.v1 */
9
+ /** Serving status enum that matches grpc.health.v1 */
10
10
  export const ServingStatus = {
11
11
  UNKNOWN: 0,
12
12
  SERVING: 1,
package/src/index.js CHANGED
@@ -23,18 +23,18 @@ export const clients = exports.clients || {};
23
23
 
24
24
  /**
25
25
  * Creates a tracer instance for a service
26
- * This factory should be called at startup in server.js files or when creating clients
27
- * @param {string} serviceName - Name of the service being traced
26
+ * Call this factory at startup in server.js files or when you create clients
27
+ * @param {string} serviceName - Name of the service to trace
28
28
  * @returns {Promise<Tracer>} Configured tracer instance
29
- * @throws {Error} If span service configuration cannot be loaded
29
+ * @throws {Error} If the factory cannot load the span service configuration
30
30
  */
31
31
  export async function createTracer(serviceName) {
32
32
  const spanConfig = await createServiceConfig("span");
33
33
  const { SpanClient } = clients;
34
- // createTracer is a composition-root factory; it builds the production
35
- // runtime as its DI root, threads it into the SpanClient (so the client's
36
- // auth reads SERVICE_SECRET off the bag) and into the Tracer (and thus every
37
- // Span) via its clock.
34
+ // createTracer is a composition-root factory. It builds the production
35
+ // runtime as its DI root. It threads the runtime into the SpanClient, so the
36
+ // client's auth reads SERVICE_SECRET off the bag. It also threads the runtime
37
+ // clock into the Tracer, and so into every Span.
38
38
  const runtime = createDefaultRuntime();
39
39
  const spanClient = new SpanClient(spanConfig, runtime);
40
40
  return new Tracer({
@@ -46,7 +46,7 @@ export async function createTracer(serviceName) {
46
46
  }
47
47
 
48
48
  /**
49
- * Factory function to create a client instance with optional logging and tracing
49
+ * Factory function to create a client instance with an optional logger and tracer
50
50
  * @param {string} name - Service name (e.g., "memory", "llm", "tool")
51
51
  * @param {object} [logger] - Optional logger instance
52
52
  * @param {import("@forwardimpact/libtelemetry").Tracer} [tracer] - Optional tracer instance for distributed tracing
@@ -64,10 +64,10 @@ export async function createClient(name, logger = null, tracer = null) {
64
64
  );
65
65
  }
66
66
 
67
- // Create config for the service
67
+ // Create the config for the service
68
68
  const config = await createServiceConfig(name);
69
69
 
70
- // createClient is a composition-root factory; build the production runtime
70
+ // createClient is a composition-root factory. Build the production runtime
71
71
  // here and thread it so the client's auth reads SERVICE_SECRET off the bag.
72
72
  const runtime = createDefaultRuntime();
73
73
 
@@ -2,7 +2,8 @@ import grpc from "@grpc/grpc-js";
2
2
 
3
3
  /**
4
4
  * gRPC interceptor for HMAC-based service authentication
5
- * Handles automatic token attachment for outgoing requests and validation for incoming requests
5
+ * Adds a token to each outgoing request automatically and validates each
6
+ * incoming request
6
7
  */
7
8
  export class Interceptor {
8
9
  #authenticator;
@@ -82,14 +83,14 @@ export class Interceptor {
82
83
  };
83
84
  }
84
85
 
85
- // Add service ID to call context for potential use by handlers
86
+ // Add the service ID to the call context so handlers can use it
86
87
  call.serviceId = verification.serviceId;
87
88
  };
88
89
  }
89
90
 
90
91
  /**
91
92
  * Validates an incoming gRPC call's authentication
92
- * This is a helper method for manual authentication validation
93
+ * This helper method validates the authentication manually
93
94
  * @param {object} call - gRPC call object
94
95
  * @returns {object} Verification result with isValid, serviceId, and error properties
95
96
  */
package/src/server.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  import { healthDefinition, createHealthHandlers } from "./health.js";
9
9
 
10
10
  /**
11
- * gRPC Server class using pre-compiled service definitions
11
+ * gRPC Server class that uses pre-compiled service definitions
12
12
  * Takes a service instance and creates a gRPC server around it
13
13
  */
14
14
  export class Server extends Rpc {
@@ -47,7 +47,7 @@ export class Server extends Rpc {
47
47
 
48
48
  /** Starts the gRPC server */
49
49
  async start() {
50
- // Configure server with keepalive for long-running streams
50
+ // Configure the server with keepalive for long-running streams
51
51
  // https://github.com/grpc/grpc-node/blob/master/doc/keepalive.md
52
52
  this.#server = new (this.grpc().Server)({
53
53
  "grpc.keepalive_time_ms": 30000, // Send keepalive ping every 30 seconds
@@ -57,19 +57,19 @@ export class Server extends Rpc {
57
57
  "grpc.http2.max_pings_without_data": 0, // Unlimited pings without data
58
58
  });
59
59
 
60
- // Get pre-compiled service definition
60
+ // Get the pre-compiled service definition
61
61
  const serviceName = capitalizeFirstLetter(this.config.name);
62
62
  const definition = this.getServiceDefinition(serviceName);
63
63
 
64
- // Get handlers from the service instance
64
+ // Get the handlers from the service instance
65
65
  const handlers = this.#service.getHandlers();
66
66
 
67
- // Wrap handlers with auth/error handling
67
+ // Wrap the handlers so they authenticate and handle errors
68
68
  const wrappedHandlers = this.#wrapHandlers(handlers, definition);
69
69
 
70
70
  this.#server.addService(definition, wrappedHandlers);
71
71
 
72
- // Register standard gRPC health check (no auth, no observer wrapping)
72
+ // Register the standard gRPC health check (no auth, no observer wrap)
73
73
  this.#server.addService(
74
74
  healthDefinition,
75
75
  createHealthHandlers(serviceName),
@@ -82,7 +82,7 @@ export class Server extends Rpc {
82
82
  }
83
83
 
84
84
  /**
85
- * Wraps handlers with auth and error handling
85
+ * Wraps the handlers so they authenticate and handle errors
86
86
  * @param {object} handlers - Service method handlers
87
87
  * @param {object} definition - Service definition
88
88
  * @returns {object} Wrapped handlers
@@ -101,7 +101,8 @@ export class Server extends Rpc {
101
101
  }
102
102
 
103
103
  /**
104
- * Wraps a streaming handler with tracing, authentication, and error handling via Observer
104
+ * Wraps a streaming handler with tracing, authentication, and error
105
+ * handling through Observer
105
106
  * @param {string} methodName - Method name for tracing
106
107
  * @param {Function} handler - Streaming handler function
107
108
  * @returns {Function} Wrapped handler
@@ -142,7 +143,8 @@ export class Server extends Rpc {
142
143
  }
143
144
 
144
145
  /**
145
- * Wraps a unary handler with tracing, authentication, and error handling via Observer
146
+ * Wraps a unary handler with tracing, authentication, and error handling
147
+ * through Observer
146
148
  * @param {string} methodName - Method name for tracing
147
149
  * @param {Function} handler - Unary handler function
148
150
  * @returns {Function} Wrapped handler
@@ -185,7 +187,7 @@ export class Server extends Rpc {
185
187
  }
186
188
 
187
189
  /**
188
- * Binds server to the specified URI
190
+ * Binds the server to the specified URI
189
191
  * @param {string} uri - Server URI to bind to
190
192
  * @returns {Promise<number>} Bound port number
191
193
  */
@@ -213,7 +215,7 @@ export class Server extends Rpc {
213
215
  const shutdown = async () => {
214
216
  this.observer().logger()?.info("Server", "Shutting down...");
215
217
 
216
- // Call service shutdown if it exists
218
+ // Call the service shutdown if it exists
217
219
  if (typeof this.#service.shutdown === "function") {
218
220
  await this.#service.shutdown();
219
221
  }