@forwardimpact/librpc 0.1.77

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/client.js ADDED
@@ -0,0 +1,220 @@
1
+ import { PassThrough } from "stream";
2
+ import { createRetry } from "@forwardimpact/libutil";
3
+
4
+ import {
5
+ Rpc,
6
+ createGrpc,
7
+ createAuth,
8
+ createObserver,
9
+ capitalizeFirstLetter,
10
+ } from "./base.js";
11
+
12
+ /**
13
+ * Creates a gRPC client with consistent API using pre-compiled definitions
14
+ */
15
+ export class Client extends Rpc {
16
+ #client;
17
+ #retry;
18
+
19
+ /**
20
+ * Creates a new Client instance
21
+ * @param {object} config - Configuration object
22
+ * @param {object} [logger] - Optional logger instance
23
+ * @param {import("@forwardimpact/libtelemetry").Tracer} [tracer] - Optional tracer for distributed tracing
24
+ * @param {(serviceName: string, logger: object, tracer: object) => object} observerFn - Observer factory
25
+ * @param {() => {grpc: object}} grpcFn - gRPC factory
26
+ * @param {(serviceName: string) => object} authFn - Auth factory
27
+ * @param {import("@forwardimpact/libutil").Retry} [retry] - Optional retry instance for handling transient errors
28
+ */
29
+ constructor(
30
+ config,
31
+ logger = null,
32
+ tracer = null,
33
+ observerFn = createObserver,
34
+ grpcFn = createGrpc,
35
+ authFn = createAuth,
36
+ retry = null,
37
+ ) {
38
+ super(config, logger, tracer, observerFn, grpcFn, authFn);
39
+ this.#retry = retry || createRetry({ retries: 10, delay: 1000 });
40
+ this.#setupClient();
41
+ }
42
+
43
+ /**
44
+ * Sets up the gRPC client using pre-compiled definition
45
+ * @private
46
+ */
47
+ #setupClient() {
48
+ const serviceName = capitalizeFirstLetter(this.config.name);
49
+ const serviceDefinition = this.getServiceDefinition(serviceName);
50
+
51
+ // In case default host is used, resort to a well-known service name
52
+ const host =
53
+ this.config.host === "0.0.0.0"
54
+ ? `${this.config.name}.guide.local`
55
+ : this.config.host;
56
+
57
+ const uri = `${host}:${this.config.port}`;
58
+ const options = {
59
+ interceptors: [this.auth().createClientInterceptor()],
60
+ };
61
+
62
+ // Configure client with keepalive for long-running streams
63
+ // https://github.com/grpc/grpc-node/blob/master/doc/keepalive.md
64
+ const channelOptions = {
65
+ "grpc.keepalive_time_ms": 30000, // Send keepalive ping every 30 seconds
66
+ "grpc.keepalive_timeout_ms": 10000, // Wait 10 seconds for ping ack
67
+ "grpc.keepalive_permit_without_calls": 1, // Allow keepalive without active calls
68
+ "grpc.http2.min_time_between_pings_ms": 10000, // Minimum 10s between pings
69
+ "grpc.http2.max_pings_without_data": 0, // Unlimited pings without data
70
+ };
71
+ const clientCredentials = this.grpc().credentials.createInsecure();
72
+
73
+ // Create client using pre-compiled service definition
74
+ const ClientConstructor = this.grpc().makeGenericClientConstructor(
75
+ serviceDefinition,
76
+ serviceName,
77
+ channelOptions,
78
+ );
79
+ this.#client = new ClientConstructor(uri, clientCredentials, options);
80
+ }
81
+
82
+ /**
83
+ * Call a gRPC method with automatic CLIENT span tracing and observability.
84
+ * Supports unary calls.
85
+ * @param {string} methodName - The name of the gRPC method to call
86
+ * @param {object} request - The request object to send
87
+ * @param {Function} [mapper] - Optional mapper function to transform response
88
+ * @returns {Promise<object>} The response from the gRPC call
89
+ */
90
+ callUnary(methodName, request, mapper = null) {
91
+ if (!this.#client[methodName]) {
92
+ throw new Error(`Method ${methodName} not found on gRPC client`);
93
+ }
94
+
95
+ return this.observer()
96
+ .observeClientUnaryCall(methodName, request, async (metadata) => {
97
+ const m = metadata || new (this.grpc().Metadata)();
98
+ return await this.#performUnaryCall(methodName, request, m);
99
+ })
100
+ .then((response) => (mapper ? mapper(response) : response));
101
+ }
102
+
103
+ /**
104
+ * Call a gRPC method with automatic CLIENT span tracing and observability.
105
+ * Supports streaming calls.
106
+ * @param {string} methodName - The name of the gRPC method to call
107
+ * @param {object} request - The request object to send
108
+ * @param {Function} [mapper] - Optional mapper function to transform chunks
109
+ * @returns {object} The stream from the gRPC call
110
+ */
111
+ callStream(methodName, request, mapper = null) {
112
+ if (!this.#client[methodName]) {
113
+ throw new Error(`Method ${methodName} not found on gRPC client`);
114
+ }
115
+
116
+ const stream = this.observer().observeClientStreamingCall(
117
+ methodName,
118
+ request,
119
+ (metadata) => {
120
+ const m = metadata || new (this.grpc().Metadata)();
121
+ return this.#performStreamCall(methodName, request, m);
122
+ },
123
+ );
124
+
125
+ if (mapper) {
126
+ const mappedStream = new PassThrough({
127
+ objectMode: true,
128
+ transform(chunk, encoding, callback) {
129
+ try {
130
+ callback(null, mapper(chunk));
131
+ } catch (err) {
132
+ callback(err);
133
+ }
134
+ },
135
+ });
136
+
137
+ stream.on("error", (err) => mappedStream.emit("error", err));
138
+ stream.pipe(mappedStream);
139
+ return mappedStream;
140
+ }
141
+
142
+ return stream;
143
+ }
144
+
145
+ /**
146
+ * Internal streaming call handler
147
+ * @param {string} methodName - The name of the method
148
+ * @param {object} request - Request object
149
+ * @param {object} metadata - gRPC Metadata instance
150
+ * @returns {object} The gRPC stream
151
+ * @private
152
+ */
153
+ #performStreamCall(methodName, request, metadata) {
154
+ const outputStream = new PassThrough({ objectMode: true });
155
+
156
+ this.#retry
157
+ .execute(() => {
158
+ return new Promise((resolve, reject) => {
159
+ const stream = this.#client[methodName](request, metadata);
160
+ let isConnected = false;
161
+
162
+ const onSuccess = () => {
163
+ if (!isConnected) {
164
+ isConnected = true;
165
+ resolve();
166
+ }
167
+ };
168
+
169
+ stream.on("metadata", (meta) => {
170
+ outputStream.emit("metadata", meta);
171
+ });
172
+
173
+ stream.on("data", (chunk) => {
174
+ onSuccess();
175
+ outputStream.write(chunk);
176
+ });
177
+
178
+ stream.on("error", (err) => {
179
+ if (!isConnected) {
180
+ reject(err);
181
+ } else {
182
+ outputStream.emit("error", err);
183
+ }
184
+ });
185
+
186
+ stream.on("end", () => {
187
+ onSuccess();
188
+ outputStream.end();
189
+ });
190
+ });
191
+ })
192
+ .catch((err) => {
193
+ outputStream.emit("error", err);
194
+ });
195
+
196
+ return outputStream;
197
+ }
198
+
199
+ /**
200
+ * Internal unary call handler with retry logic
201
+ * @param {string} methodName - The name of the method
202
+ * @param {object} request - Request object
203
+ * @param {object} metadata - gRPC Metadata instance
204
+ * @returns {Promise<object>} Response object
205
+ * @private
206
+ */
207
+ async #performUnaryCall(methodName, request, metadata) {
208
+ return await this.#retry.execute(() => {
209
+ return new Promise((resolve, reject) => {
210
+ this.#client[methodName](request, metadata, (error, response) => {
211
+ if (error) {
212
+ reject(error);
213
+ } else {
214
+ resolve(response);
215
+ }
216
+ });
217
+ });
218
+ });
219
+ }
220
+ }
package/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import grpc from "@grpc/grpc-js";
2
+
3
+ import { createServiceConfig } from "@forwardimpact/libconfig";
4
+ import { Tracer } from "@forwardimpact/libtelemetry/tracer.js";
5
+
6
+ import { capitalizeFirstLetter } from "./base.js";
7
+ import * as exports from "./generated/services/exports.js";
8
+
9
+ export { createGrpc, createAuth, Rpc } from "./base.js";
10
+ export { Client } from "./client.js";
11
+ export { Interceptor, HmacAuth } from "./auth.js";
12
+ export { Server } from "./server.js";
13
+
14
+ // Export services and clients objects for runtime access
15
+ export const services = exports.services || {};
16
+ export const clients = exports.clients || {};
17
+
18
+ /**
19
+ * Creates a tracer instance for a service
20
+ * This factory should be called at startup in server.js files or when creating clients
21
+ * @param {string} serviceName - Name of the service being traced
22
+ * @returns {Promise<Tracer>} Configured tracer instance
23
+ * @throws {Error} If trace service configuration cannot be loaded
24
+ */
25
+ export async function createTracer(serviceName) {
26
+ const traceConfig = await createServiceConfig("trace");
27
+ const { TraceClient } = clients;
28
+ const traceClient = new TraceClient(traceConfig);
29
+ return new Tracer({
30
+ serviceName,
31
+ traceClient,
32
+ grpcMetadata: grpc.Metadata,
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Factory function to create a client instance with optional logging and tracing
38
+ * @param {string} name - Service name (e.g., "memory", "llm", "tool")
39
+ * @param {object} [logger] - Optional logger instance
40
+ * @param {import("@forwardimpact/libtelemetry").Tracer} [tracer] - Optional tracer instance for distributed tracing
41
+ * @returns {Promise<object>} Initialized client instance
42
+ */
43
+ export async function createClient(name, logger = null, tracer = null) {
44
+ // Build the client class name (e.g., "memory" -> "MemoryClient")
45
+ const className = capitalizeFirstLetter(name) + "Client";
46
+
47
+ // Get the client class from exports
48
+ const ClientClass = clients[className];
49
+ if (!ClientClass) {
50
+ throw new Error(
51
+ `Client ${className} not found. Available clients: ${Object.keys(clients).join(", ")}`,
52
+ );
53
+ }
54
+
55
+ // Create config for the service
56
+ const config = await createServiceConfig(name);
57
+
58
+ // Create and return the client instance with logger and tracer
59
+ return new ClientClass(config, logger, tracer);
60
+ }
package/interceptor.js ADDED
@@ -0,0 +1,130 @@
1
+ import grpc from "@grpc/grpc-js";
2
+
3
+ /**
4
+ * gRPC interceptor for HMAC-based service authentication
5
+ * Handles automatic token attachment for outgoing requests and validation for incoming requests
6
+ */
7
+ export class Interceptor {
8
+ #authenticator;
9
+ #serviceId;
10
+
11
+ /**
12
+ * Creates a new authentication interceptor
13
+ * @param {import('./auth.js').HmacAuth} authenticator - HMAC authenticator instance
14
+ * @param {string} serviceId - Identifier of the current service
15
+ * @throws {Error} When parameters are invalid
16
+ */
17
+ constructor(authenticator, serviceId) {
18
+ if (!authenticator) {
19
+ throw new Error("Authenticator is required");
20
+ }
21
+ if (!serviceId || typeof serviceId !== "string") {
22
+ throw new Error("Service ID must be a non-empty string");
23
+ }
24
+
25
+ this.#authenticator = authenticator;
26
+ this.#serviceId = serviceId;
27
+ }
28
+
29
+ /**
30
+ * Creates a client interceptor that adds authentication tokens to outgoing requests
31
+ * @returns {Function} gRPC client interceptor function
32
+ */
33
+ createClientInterceptor() {
34
+ return (options, nextCall) => {
35
+ return new grpc.InterceptingCall(nextCall(options), {
36
+ start: (metadata, listener, next) => {
37
+ // Generate and add auth token to metadata
38
+ try {
39
+ const token = this.#authenticator.generateToken(this.#serviceId);
40
+ metadata.set("authorization", `Bearer ${token}`);
41
+ } catch (error) {
42
+ console.error("Failed to generate auth token:", error);
43
+ }
44
+ next(metadata, listener);
45
+ },
46
+ });
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Creates a server interceptor that validates authentication tokens from incoming requests
52
+ * @returns {Function} gRPC server interceptor function
53
+ */
54
+ createServerInterceptor() {
55
+ return (call, metadata) => {
56
+ // Extract and verify auth token from metadata
57
+ const authHeader = metadata.get("authorization")[0];
58
+
59
+ if (!authHeader) {
60
+ throw {
61
+ code: grpc.status.UNAUTHENTICATED,
62
+ message: "Missing authentication token",
63
+ };
64
+ }
65
+
66
+ // Extract token from "Bearer <token>" format
67
+ const tokenMatch = authHeader.match(/^Bearer\s+(.+)$/);
68
+ if (!tokenMatch) {
69
+ throw {
70
+ code: grpc.status.UNAUTHENTICATED,
71
+ message: "Invalid authentication header format",
72
+ };
73
+ }
74
+
75
+ const token = tokenMatch[1];
76
+ const verification = this.#authenticator.verifyToken(token);
77
+
78
+ if (!verification.isValid) {
79
+ throw {
80
+ code: grpc.status.UNAUTHENTICATED,
81
+ message: `Authentication failed: ${verification.error}`,
82
+ };
83
+ }
84
+
85
+ // Add service ID to call context for potential use by handlers
86
+ call.serviceId = verification.serviceId;
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Validates an incoming gRPC call's authentication
92
+ * This is a helper method for manual authentication validation
93
+ * @param {object} call - gRPC call object
94
+ * @returns {object} Verification result with isValid, serviceId, and error properties
95
+ */
96
+ validateCall(call) {
97
+ try {
98
+ const metadata = call.metadata;
99
+ const authHeaders = metadata.get("authorization");
100
+
101
+ if (!authHeaders || authHeaders.length === 0) {
102
+ return {
103
+ isValid: false,
104
+ serviceId: null,
105
+ error: "Missing authentication token",
106
+ };
107
+ }
108
+
109
+ const authHeader = authHeaders[0];
110
+ const tokenMatch = authHeader.match(/^Bearer\s+(.+)$/);
111
+
112
+ if (!tokenMatch) {
113
+ return {
114
+ isValid: false,
115
+ serviceId: null,
116
+ error: "Invalid authentication header format",
117
+ };
118
+ }
119
+
120
+ const token = tokenMatch[1];
121
+ return this.#authenticator.verifyToken(token);
122
+ } catch (error) {
123
+ return {
124
+ isValid: false,
125
+ serviceId: null,
126
+ error: `Authentication validation failed: ${error.message}`,
127
+ };
128
+ }
129
+ }
130
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@forwardimpact/librpc",
3
+ "version": "0.1.77",
4
+ "description": "gRPC framework and utilities for Guide",
5
+ "license": "Apache-2.0",
6
+ "author": "D. Olsson <hi@senzilla.io>",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "engines": {
10
+ "node": ">=22.0.0"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test test/*.test.js"
14
+ },
15
+ "dependencies": {
16
+ "@forwardimpact/libconfig": "^0.1.58",
17
+ "@forwardimpact/libtelemetry": "^0.1.22",
18
+ "@forwardimpact/libutil": "^0.1.60",
19
+ "@grpc/grpc-js": "^1.14.3"
20
+ },
21
+ "devDependencies": {
22
+ "@forwardimpact/libharness": "^0.1.5"
23
+ }
24
+ }
package/server.js ADDED
@@ -0,0 +1,212 @@
1
+ import {
2
+ Rpc,
3
+ createGrpc,
4
+ createAuth,
5
+ createObserver,
6
+ capitalizeFirstLetter,
7
+ } from "./base.js";
8
+
9
+ /**
10
+ * gRPC Server class using pre-compiled service definitions
11
+ * Takes a service instance and creates a gRPC server around it
12
+ */
13
+ export class Server extends Rpc {
14
+ #server;
15
+ #service;
16
+
17
+ /**
18
+ * Creates a gRPC server for a service
19
+ * @param {object} service - Service instance with business logic
20
+ * @param {object} config - Server configuration
21
+ * @param {object} [logger] - Optional logger instance
22
+ * @param {import("@forwardimpact/libtelemetry").Tracer} [tracer] - Optional tracer for distributed tracing
23
+ * @param {(serviceName: string, logger: object, tracer: object) => object} observerFn - Observer factory
24
+ * @param {() => {grpc: object}} grpcFn - gRPC factory
25
+ * @param {(serviceName: string) => object} authFn - Auth factory
26
+ */
27
+ constructor(
28
+ service,
29
+ config,
30
+ logger = null,
31
+ tracer = null,
32
+ observerFn = createObserver,
33
+ grpcFn = createGrpc,
34
+ authFn = createAuth,
35
+ ) {
36
+ if (!service) throw new Error("service is required");
37
+
38
+ super(config, logger, tracer, observerFn, grpcFn, authFn);
39
+ this.#service = service;
40
+ }
41
+
42
+ /** Starts the gRPC server */
43
+ async start() {
44
+ // Configure server with keepalive for long-running streams
45
+ // https://github.com/grpc/grpc-node/blob/master/doc/keepalive.md
46
+ this.#server = new (this.grpc().Server)({
47
+ "grpc.keepalive_time_ms": 30000, // Send keepalive ping every 30 seconds
48
+ "grpc.keepalive_timeout_ms": 10000, // Wait 10 seconds for ping ack
49
+ "grpc.keepalive_permit_without_calls": 1, // Allow keepalive without active calls
50
+ "grpc.http2.min_time_between_pings_ms": 10000, // Minimum 10s between pings
51
+ "grpc.http2.max_pings_without_data": 0, // Unlimited pings without data
52
+ });
53
+
54
+ // Get pre-compiled service definition
55
+ const serviceName = capitalizeFirstLetter(this.config.name);
56
+ const definition = this.getServiceDefinition(serviceName);
57
+
58
+ // Get handlers from the service instance
59
+ const handlers = this.#service.getHandlers();
60
+
61
+ // Wrap handlers with auth/error handling
62
+ const wrappedHandlers = this.#wrapHandlers(handlers, definition);
63
+
64
+ this.#server.addService(definition, wrappedHandlers);
65
+
66
+ const uri = `${this.config.host}:${this.config.port}`;
67
+ await this.#bindServer(uri);
68
+
69
+ this.#setupShutdown();
70
+ }
71
+
72
+ /**
73
+ * Wraps handlers with auth and error handling
74
+ * @param {object} handlers - Service method handlers
75
+ * @param {object} definition - Service definition
76
+ * @returns {object} Wrapped handlers
77
+ */
78
+ #wrapHandlers(handlers, definition) {
79
+ const wrapped = {};
80
+ for (const [method, handler] of Object.entries(handlers)) {
81
+ const methodDef = definition[method];
82
+ if (methodDef?.responseStream) {
83
+ wrapped[method] = this.#wrapStreaming(method, handler);
84
+ } else {
85
+ wrapped[method] = this.#wrapUnary(method, handler);
86
+ }
87
+ }
88
+ return wrapped;
89
+ }
90
+
91
+ /**
92
+ * Wraps a streaming handler with tracing, authentication, and error handling via Observer
93
+ * @param {string} methodName - Method name for tracing
94
+ * @param {Function} handler - Streaming handler function
95
+ * @returns {Function} Wrapped handler
96
+ */
97
+ #wrapStreaming(methodName, handler) {
98
+ return async (call) => {
99
+ const emitError = (code, message) =>
100
+ call.emit("error", { code, message });
101
+
102
+ // Validate call.request exists (for server streaming)
103
+ if (!call?.request) {
104
+ return emitError(
105
+ this.grpc().status.INVALID_ARGUMENT,
106
+ "Invalid request: call.request is missing",
107
+ );
108
+ }
109
+
110
+ // Authenticate
111
+ const validation = this.auth().validateCall(call);
112
+ if (!validation.isValid) {
113
+ return emitError(
114
+ this.grpc().status.UNAUTHENTICATED,
115
+ `Authentication failed: ${validation.error}`,
116
+ );
117
+ }
118
+
119
+ // Observer handles everything: spans, events, metadata, logging
120
+ try {
121
+ await this.observer().observeServerStreamingCall(
122
+ methodName,
123
+ call,
124
+ handler,
125
+ );
126
+ } catch (error) {
127
+ emitError(this.grpc().status.INTERNAL, error?.message || String(error));
128
+ }
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Wraps a unary handler with tracing, authentication, and error handling via Observer
134
+ * @param {string} methodName - Method name for tracing
135
+ * @param {Function} handler - Unary handler function
136
+ * @returns {Function} Wrapped handler
137
+ */
138
+ #wrapUnary(methodName, handler) {
139
+ return async (call, callback) => {
140
+ // Validate call.request exists
141
+ if (!call?.request) {
142
+ return callback({
143
+ code: this.grpc().status.INVALID_ARGUMENT,
144
+ message: "Invalid request: call.request is missing",
145
+ });
146
+ }
147
+
148
+ // Authenticate
149
+ const validation = this.auth().validateCall(call);
150
+ if (!validation.isValid) {
151
+ return callback({
152
+ code: this.grpc().status.UNAUTHENTICATED,
153
+ message: `Authentication failed: ${validation.error}`,
154
+ });
155
+ }
156
+
157
+ // Observer handles everything: spans, events, metadata, logging
158
+ try {
159
+ const response = await this.observer().observeServerUnaryCall(
160
+ methodName,
161
+ call,
162
+ async (call) => await handler(call),
163
+ );
164
+ callback(null, response);
165
+ } catch (error) {
166
+ callback({
167
+ code: this.grpc().status.INTERNAL,
168
+ message: error?.message || String(error),
169
+ });
170
+ }
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Binds server to the specified URI
176
+ * @param {string} uri - Server URI to bind to
177
+ * @returns {Promise<number>} Bound port number
178
+ */
179
+ async #bindServer(uri) {
180
+ return new Promise((resolve, reject) => {
181
+ this.#server.bindAsync(
182
+ uri,
183
+ this.grpc().ServerCredentials.createInsecure(),
184
+ (error, port) => {
185
+ if (error) {
186
+ this.observer().logger()?.error("Server", error);
187
+ reject(error);
188
+ } else {
189
+ this.observer().logger()?.info("Server", "Listening", { uri });
190
+ resolve(port);
191
+ }
192
+ },
193
+ );
194
+ });
195
+ }
196
+
197
+ /** Sets up graceful shutdown handlers */
198
+ #setupShutdown() {
199
+ const shutdown = async () => {
200
+ this.observer().logger()?.info("Server", "Shutting down...");
201
+
202
+ // Call service shutdown if it exists
203
+ if (typeof this.#service.shutdown === "function") {
204
+ await this.#service.shutdown();
205
+ }
206
+
207
+ this.#server.tryShutdown(() => process.exit(0));
208
+ };
209
+ process.on("SIGINT", shutdown);
210
+ process.on("SIGTERM", shutdown);
211
+ }
212
+ }