@forwardimpact/librpc 0.1.85 → 0.1.88
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/bin/fit-unary.js +30 -9
- package/health.js +93 -0
- package/index.js +5 -0
- package/package.json +2 -1
- package/server.js +7 -0
- package/test/health.test.js +170 -0
package/bin/fit-unary.js
CHANGED
|
@@ -1,24 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createCli } from "@forwardimpact/libcli";
|
|
2
4
|
import { createClient, createTracer } from "@forwardimpact/librpc";
|
|
3
5
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
4
6
|
|
|
7
|
+
const { version: VERSION } = JSON.parse(
|
|
8
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const definition = {
|
|
12
|
+
name: "fit-unary",
|
|
13
|
+
version: VERSION,
|
|
14
|
+
description: "Make a unary gRPC call to a service",
|
|
15
|
+
usage: "fit-unary <service> <method> [json-request]",
|
|
16
|
+
options: {
|
|
17
|
+
help: { type: "boolean", short: "h", description: "Show this help" },
|
|
18
|
+
version: { type: "boolean", description: "Show version" },
|
|
19
|
+
json: { type: "boolean", description: "Output help as JSON" },
|
|
20
|
+
},
|
|
21
|
+
examples: ['fit-unary memory GetWindow \'{"resource_id":"..."}\''],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const cli = createCli(definition);
|
|
25
|
+
const logger = createLogger("cli");
|
|
26
|
+
|
|
5
27
|
/**
|
|
6
28
|
* Makes a unary gRPC call to a service
|
|
7
|
-
* Usage: fit-unary <service> <method> [json-request]
|
|
8
29
|
* @returns {Promise<void>}
|
|
9
30
|
*/
|
|
10
31
|
async function main() {
|
|
11
|
-
const
|
|
32
|
+
const parsed = cli.parse(process.argv.slice(2));
|
|
33
|
+
if (!parsed) process.exit(0);
|
|
34
|
+
|
|
35
|
+
const [service, method, requestJson] = parsed.positionals;
|
|
12
36
|
if (!service || !method) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'Example: fit-unary memory GetWindow \'{"resource_id":"..."}\'',
|
|
16
|
-
);
|
|
17
|
-
process.exit(1);
|
|
37
|
+
cli.usageError("expected arguments: <service> <method> [json-request]");
|
|
38
|
+
process.exit(2);
|
|
18
39
|
}
|
|
19
40
|
|
|
20
41
|
const request = requestJson ? JSON.parse(requestJson) : {};
|
|
21
|
-
const logger = createLogger("cli");
|
|
22
42
|
const tracer = await createTracer("cli");
|
|
23
43
|
const client = await createClient(service, logger, tracer);
|
|
24
44
|
|
|
@@ -27,6 +47,7 @@ async function main() {
|
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
main().catch((error) => {
|
|
30
|
-
|
|
50
|
+
logger.exception("main", error);
|
|
51
|
+
cli.error(error.message);
|
|
31
52
|
process.exit(1);
|
|
32
53
|
});
|
package/health.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gRPC Health Check protocol (grpc.health.v1.Health/Check)
|
|
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).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Serving status enum matching grpc.health.v1 */
|
|
10
|
+
export const ServingStatus = {
|
|
11
|
+
UNKNOWN: 0,
|
|
12
|
+
SERVING: 1,
|
|
13
|
+
NOT_SERVING: 2,
|
|
14
|
+
SERVICE_UNKNOWN: 3,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Serialize a HealthCheckRequest to bytes.
|
|
19
|
+
* Proto: message HealthCheckRequest { string service = 1; }
|
|
20
|
+
*/
|
|
21
|
+
function serializeRequest(value) {
|
|
22
|
+
const service = value?.service || "";
|
|
23
|
+
if (service.length === 0) return Buffer.alloc(0);
|
|
24
|
+
const encoded = Buffer.from(service, "utf8");
|
|
25
|
+
// field 1, wire type 2 (length-delimited): tag = 0x0a
|
|
26
|
+
const header = Buffer.alloc(2);
|
|
27
|
+
header[0] = 0x0a;
|
|
28
|
+
header[1] = encoded.length; // works for lengths < 128
|
|
29
|
+
return Buffer.concat([header, encoded]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deserialize bytes to a HealthCheckRequest.
|
|
34
|
+
*/
|
|
35
|
+
function deserializeRequest(buffer) {
|
|
36
|
+
if (!buffer || buffer.length === 0) return { service: "" };
|
|
37
|
+
if (buffer[0] === 0x0a) {
|
|
38
|
+
const len = buffer[1];
|
|
39
|
+
return { service: buffer.subarray(2, 2 + len).toString("utf8") };
|
|
40
|
+
}
|
|
41
|
+
return { service: "" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Serialize a HealthCheckResponse to bytes.
|
|
46
|
+
* Proto: message HealthCheckResponse { ServingStatus status = 1; }
|
|
47
|
+
*/
|
|
48
|
+
function serializeResponse(value) {
|
|
49
|
+
const status = value?.status ?? 0;
|
|
50
|
+
if (status === 0) return Buffer.alloc(0);
|
|
51
|
+
// field 1, wire type 0 (varint): tag = 0x08
|
|
52
|
+
return Buffer.from([0x08, status]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deserialize bytes to a HealthCheckResponse.
|
|
57
|
+
*/
|
|
58
|
+
function deserializeResponse(buffer) {
|
|
59
|
+
if (!buffer || buffer.length === 0) return { status: 0 };
|
|
60
|
+
if (buffer[0] === 0x08) return { status: buffer[1] };
|
|
61
|
+
return { status: 0 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Service definition compatible with grpc.Server.addService */
|
|
65
|
+
export const healthDefinition = {
|
|
66
|
+
Check: {
|
|
67
|
+
path: "/grpc.health.v1.Health/Check",
|
|
68
|
+
requestStream: false,
|
|
69
|
+
responseStream: false,
|
|
70
|
+
requestSerialize: serializeRequest,
|
|
71
|
+
requestDeserialize: deserializeRequest,
|
|
72
|
+
responseSerialize: serializeResponse,
|
|
73
|
+
responseDeserialize: deserializeResponse,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates health check handlers for the given service name.
|
|
79
|
+
* @param {string} serviceName - The application service name (e.g., "Graph")
|
|
80
|
+
* @returns {object} Handler map for the Health service definition
|
|
81
|
+
*/
|
|
82
|
+
export function createHealthHandlers(serviceName) {
|
|
83
|
+
return {
|
|
84
|
+
Check: (_call, callback) => {
|
|
85
|
+
const requestedService = _call.request?.service || "";
|
|
86
|
+
|
|
87
|
+
if (requestedService === "" || requestedService === serviceName) {
|
|
88
|
+
return callback(null, { status: ServingStatus.SERVING });
|
|
89
|
+
}
|
|
90
|
+
return callback(null, { status: ServingStatus.SERVICE_UNKNOWN });
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
package/index.js
CHANGED
|
@@ -10,6 +10,11 @@ export { createGrpc, createAuth, Rpc } from "./base.js";
|
|
|
10
10
|
export { Client } from "./client.js";
|
|
11
11
|
export { Interceptor, HmacAuth } from "./auth.js";
|
|
12
12
|
export { Server } from "./server.js";
|
|
13
|
+
export {
|
|
14
|
+
healthDefinition,
|
|
15
|
+
createHealthHandlers,
|
|
16
|
+
ServingStatus,
|
|
17
|
+
} from "./health.js";
|
|
13
18
|
|
|
14
19
|
// Export services and clients objects for runtime access
|
|
15
20
|
export const services = exports.services || {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/librpc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.88",
|
|
4
4
|
"description": "gRPC framework and utilities for Guide",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "D. Olsson <hi@senzilla.io>",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"test": "bun run node --test test/*.test.js"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@forwardimpact/libcli": "^0.1.0",
|
|
20
21
|
"@forwardimpact/libconfig": "^0.1.58",
|
|
21
22
|
"@forwardimpact/libtelemetry": "^0.1.22",
|
|
22
23
|
"@forwardimpact/libutil": "^0.1.60",
|
package/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
createObserver,
|
|
6
6
|
capitalizeFirstLetter,
|
|
7
7
|
} from "./base.js";
|
|
8
|
+
import { healthDefinition, createHealthHandlers } from "./health.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* gRPC Server class using pre-compiled service definitions
|
|
@@ -63,6 +64,12 @@ export class Server extends Rpc {
|
|
|
63
64
|
|
|
64
65
|
this.#server.addService(definition, wrappedHandlers);
|
|
65
66
|
|
|
67
|
+
// Register standard gRPC health check (no auth, no observer wrapping)
|
|
68
|
+
this.#server.addService(
|
|
69
|
+
healthDefinition,
|
|
70
|
+
createHealthHandlers(serviceName),
|
|
71
|
+
);
|
|
72
|
+
|
|
66
73
|
const uri = `${this.config.host}:${this.config.port}`;
|
|
67
74
|
await this.#bindServer(uri);
|
|
68
75
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { test, describe } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
healthDefinition,
|
|
6
|
+
createHealthHandlers,
|
|
7
|
+
ServingStatus,
|
|
8
|
+
Server,
|
|
9
|
+
} from "../index.js";
|
|
10
|
+
import {
|
|
11
|
+
createMockConfig,
|
|
12
|
+
createMockGrpcFn,
|
|
13
|
+
createMockObserverFn,
|
|
14
|
+
createMockAuthFn,
|
|
15
|
+
createMockLogger,
|
|
16
|
+
} from "@forwardimpact/libharness";
|
|
17
|
+
|
|
18
|
+
describe("healthDefinition", () => {
|
|
19
|
+
test("Check has the required service definition fields", () => {
|
|
20
|
+
const check = healthDefinition.Check;
|
|
21
|
+
assert.strictEqual(check.path, "/grpc.health.v1.Health/Check");
|
|
22
|
+
assert.strictEqual(check.requestStream, false);
|
|
23
|
+
assert.strictEqual(check.responseStream, false);
|
|
24
|
+
assert.strictEqual(typeof check.requestSerialize, "function");
|
|
25
|
+
assert.strictEqual(typeof check.requestDeserialize, "function");
|
|
26
|
+
assert.strictEqual(typeof check.responseSerialize, "function");
|
|
27
|
+
assert.strictEqual(typeof check.responseDeserialize, "function");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("request serialization round-trip with service name", () => {
|
|
31
|
+
const original = { service: "Graph" };
|
|
32
|
+
const bytes = healthDefinition.Check.requestSerialize(original);
|
|
33
|
+
const decoded = healthDefinition.Check.requestDeserialize(bytes);
|
|
34
|
+
assert.strictEqual(decoded.service, "Graph");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("request serialization round-trip with empty service", () => {
|
|
38
|
+
const original = { service: "" };
|
|
39
|
+
const bytes = healthDefinition.Check.requestSerialize(original);
|
|
40
|
+
const decoded = healthDefinition.Check.requestDeserialize(bytes);
|
|
41
|
+
assert.strictEqual(decoded.service, "");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("response serialization round-trip SERVING", () => {
|
|
45
|
+
const original = { status: ServingStatus.SERVING };
|
|
46
|
+
const bytes = healthDefinition.Check.responseSerialize(original);
|
|
47
|
+
const decoded = healthDefinition.Check.responseDeserialize(bytes);
|
|
48
|
+
assert.strictEqual(decoded.status, ServingStatus.SERVING);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("response serialization round-trip UNKNOWN (proto3 default)", () => {
|
|
52
|
+
const original = { status: ServingStatus.UNKNOWN };
|
|
53
|
+
const bytes = healthDefinition.Check.responseSerialize(original);
|
|
54
|
+
const decoded = healthDefinition.Check.responseDeserialize(bytes);
|
|
55
|
+
assert.strictEqual(decoded.status, ServingStatus.UNKNOWN);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("response serialization round-trip SERVICE_UNKNOWN", () => {
|
|
59
|
+
const original = { status: ServingStatus.SERVICE_UNKNOWN };
|
|
60
|
+
const bytes = healthDefinition.Check.responseSerialize(original);
|
|
61
|
+
const decoded = healthDefinition.Check.responseDeserialize(bytes);
|
|
62
|
+
assert.strictEqual(decoded.status, ServingStatus.SERVICE_UNKNOWN);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("createHealthHandlers", () => {
|
|
67
|
+
test("empty service name returns SERVING", (_, done) => {
|
|
68
|
+
const handlers = createHealthHandlers("Graph");
|
|
69
|
+
handlers.Check({ request: { service: "" } }, (err, response) => {
|
|
70
|
+
assert.strictEqual(err, null);
|
|
71
|
+
assert.strictEqual(response.status, ServingStatus.SERVING);
|
|
72
|
+
done();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("matching service name returns SERVING", (_, done) => {
|
|
77
|
+
const handlers = createHealthHandlers("Graph");
|
|
78
|
+
handlers.Check({ request: { service: "Graph" } }, (err, response) => {
|
|
79
|
+
assert.strictEqual(err, null);
|
|
80
|
+
assert.strictEqual(response.status, ServingStatus.SERVING);
|
|
81
|
+
done();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("unknown service name returns SERVICE_UNKNOWN", (_, done) => {
|
|
86
|
+
const handlers = createHealthHandlers("Graph");
|
|
87
|
+
handlers.Check({ request: { service: "Nonexistent" } }, (err, response) => {
|
|
88
|
+
assert.strictEqual(err, null);
|
|
89
|
+
assert.strictEqual(response.status, ServingStatus.SERVICE_UNKNOWN);
|
|
90
|
+
done();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("Server health registration", () => {
|
|
96
|
+
test("start() registers health service alongside application service", async () => {
|
|
97
|
+
const mockService = {
|
|
98
|
+
getHandlers: () => ({
|
|
99
|
+
TestMethod: async () => ({ result: "test" }),
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const mockConfig = createMockConfig("memory", {
|
|
104
|
+
host: "0.0.0.0",
|
|
105
|
+
port: 5000,
|
|
106
|
+
});
|
|
107
|
+
const mockGrpcFn = createMockGrpcFn();
|
|
108
|
+
const mockAuthFn = createMockAuthFn();
|
|
109
|
+
const mockLogFn = createMockLogger();
|
|
110
|
+
const mockObserverFn = createMockObserverFn(mockLogFn);
|
|
111
|
+
|
|
112
|
+
const server = new Server(
|
|
113
|
+
mockService,
|
|
114
|
+
mockConfig,
|
|
115
|
+
mockLogFn,
|
|
116
|
+
null,
|
|
117
|
+
mockObserverFn,
|
|
118
|
+
mockGrpcFn,
|
|
119
|
+
mockAuthFn,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await server.start();
|
|
123
|
+
assert.ok(server);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("health handlers bypass auth wrapping", async () => {
|
|
127
|
+
let authCallCount = 0;
|
|
128
|
+
const trackingAuthFn = () => ({
|
|
129
|
+
createClientInterceptor: () => () => {},
|
|
130
|
+
validateCall: () => {
|
|
131
|
+
authCallCount++;
|
|
132
|
+
return { isValid: true, serviceId: "test" };
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const mockService = {
|
|
137
|
+
getHandlers: () => ({
|
|
138
|
+
TestMethod: async () => ({ result: "test" }),
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const mockConfig = createMockConfig("memory", {
|
|
143
|
+
host: "0.0.0.0",
|
|
144
|
+
port: 5000,
|
|
145
|
+
});
|
|
146
|
+
const mockGrpcFn = createMockGrpcFn();
|
|
147
|
+
const mockLogFn = createMockLogger();
|
|
148
|
+
const mockObserverFn = createMockObserverFn(mockLogFn);
|
|
149
|
+
|
|
150
|
+
const server = new Server(
|
|
151
|
+
mockService,
|
|
152
|
+
mockConfig,
|
|
153
|
+
mockLogFn,
|
|
154
|
+
null,
|
|
155
|
+
mockObserverFn,
|
|
156
|
+
mockGrpcFn,
|
|
157
|
+
trackingAuthFn,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
await server.start();
|
|
161
|
+
|
|
162
|
+
const handlers = createHealthHandlers("Memory");
|
|
163
|
+
const initialCount = authCallCount;
|
|
164
|
+
handlers.Check({ request: { service: "" } }, (err, response) => {
|
|
165
|
+
assert.strictEqual(err, null);
|
|
166
|
+
assert.strictEqual(response.status, ServingStatus.SERVING);
|
|
167
|
+
});
|
|
168
|
+
assert.strictEqual(authCallCount, initialCount);
|
|
169
|
+
});
|
|
170
|
+
});
|