@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/LICENSE +201 -0
- package/auth.js +269 -0
- package/base.js +126 -0
- package/client.js +220 -0
- package/index.js +60 -0
- package/interceptor.js +130 -0
- package/package.json +24 -0
- package/server.js +212 -0
- package/test/auth.test.js +94 -0
- package/test/client.test.js +138 -0
- package/test/server.test.js +123 -0
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
|
+
}
|