@m1kad0/hannah-grpc-lib 0.3.0 → 0.4.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 CHANGED
@@ -121,3 +121,30 @@ line.
121
121
  asynchronous, connection problems are reported once and retried with backoff.
122
122
  - **Process exit:** an open connection keeps Node's event loop alive. Call
123
123
  `shipper.close()` on shutdown.
124
+
125
+ ## Calling Hannah Core (`client`)
126
+
127
+ Works with `hannah.v1` types and falls back to the previous, unversioned API when Hannah
128
+ Core is too old for `hannah.v1`, so a component can be updated before Core.
129
+
130
+ ```ts
131
+ import * as grpc from '@grpc/grpc-js';
132
+ import { client } from '@m1kad0/hannah-grpc-lib';
133
+
134
+ const hannah = new client.VersionedClient(address, grpc.credentials.createInsecure(), {
135
+ warn: message => log.warn(message), // defaults to console.warn
136
+ });
137
+ const c = await hannah.resolve(); // a v1.hannah.HannahServiceClient
138
+ c.submitText({ text: '...', sourceService: 'x', sourceUserId: '' }, (err, res) => { /* ... */ });
139
+ // after a reconnect (e.g. a stream was lost):
140
+ hannah.reset();
141
+ // on shutdown:
142
+ hannah.close();
143
+ ```
144
+
145
+ - **Probe:** once per connection, `GetSatellites` on the `hannah.v1` path decides. If
146
+ Core answers `UNIMPLEMENTED`, all calls use the previous API and `warn` is called once.
147
+ Any other error isn't cached; the next `resolve()` probes again. A single method Core
148
+ doesn't know yet does not switch the connection.
149
+ - **Headers:** `x-proto-version` and `x-compat-version` are attached by default
150
+ (`client.DEFAULT_INTERCEPTORS`); pass `clientOptions` to override.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Calling Hannah Core across API generations (hannah.v1 with fallback to N−1).
3
+ * Exported as the `client` namespace of '@m1kad0/hannah-grpc-lib'. See versioned.ts.
4
+ */
5
+ export { VersionedClient, legacyDefinition, protoVersionInterceptor, DEFAULT_INTERCEPTORS, DEFAULT_PROBE_TIMEOUT_MS, CURRENT_SERVICE, LEGACY_SERVICE, } from './versioned';
6
+ export type { HannahServiceClient, VersionedClientOptions } from './versioned';
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_SERVICE = exports.CURRENT_SERVICE = exports.DEFAULT_PROBE_TIMEOUT_MS = exports.DEFAULT_INTERCEPTORS = exports.protoVersionInterceptor = exports.legacyDefinition = exports.VersionedClient = void 0;
4
+ /**
5
+ * Calling Hannah Core across API generations (hannah.v1 with fallback to N−1).
6
+ * Exported as the `client` namespace of '@m1kad0/hannah-grpc-lib'. See versioned.ts.
7
+ */
8
+ var versioned_1 = require("./versioned");
9
+ Object.defineProperty(exports, "VersionedClient", { enumerable: true, get: function () { return versioned_1.VersionedClient; } });
10
+ Object.defineProperty(exports, "legacyDefinition", { enumerable: true, get: function () { return versioned_1.legacyDefinition; } });
11
+ Object.defineProperty(exports, "protoVersionInterceptor", { enumerable: true, get: function () { return versioned_1.protoVersionInterceptor; } });
12
+ Object.defineProperty(exports, "DEFAULT_INTERCEPTORS", { enumerable: true, get: function () { return versioned_1.DEFAULT_INTERCEPTORS; } });
13
+ Object.defineProperty(exports, "DEFAULT_PROBE_TIMEOUT_MS", { enumerable: true, get: function () { return versioned_1.DEFAULT_PROBE_TIMEOUT_MS; } });
14
+ Object.defineProperty(exports, "CURRENT_SERVICE", { enumerable: true, get: function () { return versioned_1.CURRENT_SERVICE; } });
15
+ Object.defineProperty(exports, "LEGACY_SERVICE", { enumerable: true, get: function () { return versioned_1.LEGACY_SERVICE; } });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Client side of the N/N−1 API generations (hannah-proto#11).
3
+ *
4
+ * The caller works with the current generation's types (hannah.v1) only. Which method path
5
+ * goes out on the wire is decided once per connection by a probe:
6
+ *
7
+ * - `/hannah.v1.HannahService/<Method>` if Core serves the current generation,
8
+ * - `/hannah.HannahService/<Method>` (N−1) if the probe gets UNIMPLEMENTED, i.e. Core is
9
+ * too old to know v1. A warning says so once per connection.
10
+ *
11
+ * Both paths use the current generation's (de)serializers, mirroring Core's N−1 servicer:
12
+ * the packages are wire-identical, only the package name in the path differs.
13
+ *
14
+ * Only the probe decides. UNIMPLEMENTED on an ordinary call can also mean that just this one
15
+ * method is newer than the Core, which must not switch the whole connection. Any other probe
16
+ * failure (Core down, ...) is no version signal: nothing is cached, the next call probes
17
+ * again. Call `reset()` when a connection is re-established (e.g. a stream reconnects).
18
+ */
19
+ import * as grpc from '@grpc/grpc-js';
20
+ import { v1 } from '@m1kad0/hannah-proto';
21
+ export declare const CURRENT_SERVICE = "hannah.v1.HannahService";
22
+ export declare const LEGACY_SERVICE = "hannah.HannahService";
23
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 5000;
24
+ export type HannahServiceClient = v1.hannah.HannahServiceClient;
25
+ /** Attaches x-proto-version to every call (diagnostics only since hannah#359). */
26
+ export declare const protoVersionInterceptor: grpc.Interceptor;
27
+ /**
28
+ * x-proto-version and x-compat-version. hannah-proto's compat interceptor is keyed by the
29
+ * full method path across both packages, so the N−1 path gets its own schema's value.
30
+ */
31
+ export declare const DEFAULT_INTERCEPTORS: grpc.Interceptor[];
32
+ type ServiceDefinition = typeof v1.hannah.HannahServiceService;
33
+ /** The v1 service definition with every path moved to the unversioned (N−1) service. */
34
+ export declare function legacyDefinition(definition?: ServiceDefinition): ServiceDefinition;
35
+ export interface VersionedClientOptions {
36
+ /** Passed to both clients; `interceptors` defaults to DEFAULT_INTERCEPTORS. */
37
+ clientOptions?: Partial<grpc.ClientOptions>;
38
+ probeTimeoutMs?: number;
39
+ /** Where the "Core is outdated" warning goes. Defaults to console.warn. */
40
+ warn?: (message: string) => void;
41
+ }
42
+ /**
43
+ * v1 client that falls back to N−1 when Core is too old. Both clients share one channel.
44
+ *
45
+ * const clients = new VersionedClient(address, grpc.credentials.createInsecure());
46
+ * const client = await clients.resolve();
47
+ * client.submitText({ text: '...' }, (err, res) => ...);
48
+ */
49
+ export declare class VersionedClient {
50
+ private readonly current;
51
+ private readonly legacyClient;
52
+ private readonly probeTimeoutMs;
53
+ private readonly warn;
54
+ private useLegacy;
55
+ private probing;
56
+ constructor(address: string, credentials: grpc.ChannelCredentials, options?: VersionedClientOptions);
57
+ /** True while calls go to the N−1 path. */
58
+ get legacy(): boolean;
59
+ /** Name of the service whose path is in use. */
60
+ get service(): string;
61
+ /** Forget the probe result; the next `resolve()` probes again. */
62
+ reset(): void;
63
+ /** The client for the path to use, probing first if that isn't known yet. */
64
+ resolve(): Promise<HannahServiceClient>;
65
+ close(): void;
66
+ private probe;
67
+ }
68
+ export {};
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.VersionedClient = exports.DEFAULT_INTERCEPTORS = exports.protoVersionInterceptor = exports.DEFAULT_PROBE_TIMEOUT_MS = exports.LEGACY_SERVICE = exports.CURRENT_SERVICE = void 0;
37
+ exports.legacyDefinition = legacyDefinition;
38
+ /**
39
+ * Client side of the N/N−1 API generations (hannah-proto#11).
40
+ *
41
+ * The caller works with the current generation's types (hannah.v1) only. Which method path
42
+ * goes out on the wire is decided once per connection by a probe:
43
+ *
44
+ * - `/hannah.v1.HannahService/<Method>` if Core serves the current generation,
45
+ * - `/hannah.HannahService/<Method>` (N−1) if the probe gets UNIMPLEMENTED, i.e. Core is
46
+ * too old to know v1. A warning says so once per connection.
47
+ *
48
+ * Both paths use the current generation's (de)serializers, mirroring Core's N−1 servicer:
49
+ * the packages are wire-identical, only the package name in the path differs.
50
+ *
51
+ * Only the probe decides. UNIMPLEMENTED on an ordinary call can also mean that just this one
52
+ * method is newer than the Core, which must not switch the whole connection. Any other probe
53
+ * failure (Core down, ...) is no version signal: nothing is cached, the next call probes
54
+ * again. Call `reset()` when a connection is re-established (e.g. a stream reconnects).
55
+ */
56
+ const grpc = __importStar(require("@grpc/grpc-js"));
57
+ const hannah_proto_1 = require("@m1kad0/hannah-proto");
58
+ exports.CURRENT_SERVICE = 'hannah.v1.HannahService';
59
+ exports.LEGACY_SERVICE = 'hannah.HannahService';
60
+ exports.DEFAULT_PROBE_TIMEOUT_MS = 5000;
61
+ /** Attaches x-proto-version to every call (diagnostics only since hannah#359). */
62
+ const protoVersionInterceptor = (options, nextCall) => new grpc.InterceptingCall(nextCall(options), {
63
+ start(metadata, listener, next) {
64
+ metadata.set('x-proto-version', String(hannah_proto_1.PROTO_VERSION));
65
+ next(metadata, listener);
66
+ },
67
+ });
68
+ exports.protoVersionInterceptor = protoVersionInterceptor;
69
+ /**
70
+ * x-proto-version and x-compat-version. hannah-proto's compat interceptor is keyed by the
71
+ * full method path across both packages, so the N−1 path gets its own schema's value.
72
+ */
73
+ exports.DEFAULT_INTERCEPTORS = [
74
+ exports.protoVersionInterceptor,
75
+ hannah_proto_1.compat_interceptor.compatVersionInterceptor,
76
+ ];
77
+ /** The v1 service definition with every path moved to the unversioned (N−1) service. */
78
+ function legacyDefinition(definition = hannah_proto_1.v1.hannah.HannahServiceService) {
79
+ const prefix = `/${exports.CURRENT_SERVICE}/`;
80
+ const legacy = {};
81
+ for (const [name, method] of Object.entries(definition)) {
82
+ legacy[name] = { ...method, path: method.path.replace(prefix, `/${exports.LEGACY_SERVICE}/`) };
83
+ }
84
+ return legacy;
85
+ }
86
+ const LegacyHannahServiceClient = grpc.makeGenericClientConstructor(legacyDefinition(), 'HannahService');
87
+ /**
88
+ * v1 client that falls back to N−1 when Core is too old. Both clients share one channel.
89
+ *
90
+ * const clients = new VersionedClient(address, grpc.credentials.createInsecure());
91
+ * const client = await clients.resolve();
92
+ * client.submitText({ text: '...' }, (err, res) => ...);
93
+ */
94
+ class VersionedClient {
95
+ current;
96
+ legacyClient;
97
+ probeTimeoutMs;
98
+ warn;
99
+ useLegacy = null; // null = not probed yet (or probe failed)
100
+ probing = null;
101
+ constructor(address, credentials, options = {}) {
102
+ const clientOptions = { interceptors: exports.DEFAULT_INTERCEPTORS, ...options.clientOptions };
103
+ this.current = new hannah_proto_1.v1.hannah.HannahServiceClient(address, credentials, clientOptions);
104
+ this.legacyClient = new LegacyHannahServiceClient(address, credentials, {
105
+ ...clientOptions,
106
+ channelOverride: this.current.getChannel(),
107
+ });
108
+ this.probeTimeoutMs = options.probeTimeoutMs ?? exports.DEFAULT_PROBE_TIMEOUT_MS;
109
+ this.warn = options.warn ?? ((message) => console.warn(message));
110
+ }
111
+ /** True while calls go to the N−1 path. */
112
+ get legacy() {
113
+ return this.useLegacy === true;
114
+ }
115
+ /** Name of the service whose path is in use. */
116
+ get service() {
117
+ return this.useLegacy ? exports.LEGACY_SERVICE : exports.CURRENT_SERVICE;
118
+ }
119
+ /** Forget the probe result; the next `resolve()` probes again. */
120
+ reset() {
121
+ this.useLegacy = null;
122
+ }
123
+ /** The client for the path to use, probing first if that isn't known yet. */
124
+ async resolve() {
125
+ if (this.useLegacy === null) {
126
+ this.probing ??= this.probe().finally(() => {
127
+ this.probing = null;
128
+ });
129
+ await this.probing;
130
+ }
131
+ return this.useLegacy ? this.legacyClient : this.current;
132
+ }
133
+ close() {
134
+ this.legacyClient.close();
135
+ this.current.close();
136
+ }
137
+ probe() {
138
+ return new Promise(resolve => {
139
+ const deadline = new Date(Date.now() + this.probeTimeoutMs);
140
+ this.current.getSatellites({}, new grpc.Metadata(), { deadline }, (err) => {
141
+ if (!err) {
142
+ this.useLegacy = false;
143
+ }
144
+ else if (err.code === grpc.status.UNIMPLEMENTED) {
145
+ this.useLegacy = true;
146
+ this.warn(`Hannah Core doesn't serve ${exports.CURRENT_SERVICE} yet — falling back to ${exports.LEGACY_SERVICE}. Please update Hannah Core.`);
147
+ }
148
+ // Any other error is no version signal: stay undecided, the caller's own call
149
+ // fails the same way and its retry logic applies.
150
+ resolve();
151
+ });
152
+ });
153
+ }
154
+ }
155
+ exports.VersionedClient = VersionedClient;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Shared gRPC client code for Hannah components.
3
3
  *
4
- * import { logging } from '@m1kad0/hannah-grpc-lib';
4
+ * import { client, logging } from '@m1kad0/hannah-grpc-lib';
5
5
  * const shipper = new logging.LogShipper({ component: 'msteams', version });
6
+ * const hannah = new client.VersionedClient(address, credentials);
6
7
  */
8
+ export * as client from './client';
7
9
  export * as logging from './logging';
package/dist/index.js CHANGED
@@ -33,11 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.logging = void 0;
36
+ exports.logging = exports.client = void 0;
37
37
  /**
38
38
  * Shared gRPC client code for Hannah components.
39
39
  *
40
- * import { logging } from '@m1kad0/hannah-grpc-lib';
40
+ * import { client, logging } from '@m1kad0/hannah-grpc-lib';
41
41
  * const shipper = new logging.LogShipper({ component: 'msteams', version });
42
+ * const hannah = new client.VersionedClient(address, credentials);
42
43
  */
44
+ exports.client = __importStar(require("./client"));
43
45
  exports.logging = __importStar(require("./logging"));
@@ -39,6 +39,7 @@ exports.formatAddress = formatAddress;
39
39
  const node_events_1 = require("node:events");
40
40
  const grpc = __importStar(require("@grpc/grpc-js"));
41
41
  const hannah_proto_1 = require("@m1kad0/hannah-proto");
42
+ const versioned_1 = require("../client/versioned");
42
43
  const buffer_1 = require("./buffer");
43
44
  const instance_1 = require("./instance");
44
45
  const secrets_1 = require("./secrets");
@@ -68,14 +69,12 @@ const LEVELS = {
68
69
  function toLogLevel(level) {
69
70
  return typeof level === 'number' ? level : (LEVELS[level] ?? hannah_proto_1.logging.LogLevel.LOG_LEVEL_INFO);
70
71
  }
71
- const protocolVersionInterceptor = (options, nextCall) => new grpc.InterceptingCall(nextCall(options), {
72
- start(metadata, listener, next) {
73
- metadata.set('x-proto-version', String(hannah_proto_1.PROTO_VERSION));
74
- next(metadata, listener);
75
- },
76
- });
72
+ // Discovery talks to Hannah Core, over hannah.v1 with fallback to N−1 (../client). The
73
+ // collector's LogService stays on the unversioned package: that API is served by the
74
+ // collector, not Core, and moves to a versioned one together with the collector.
75
+ var infrastructure = hannah_proto_1.v1.infrastructure;
77
76
  const CLIENT_OPTIONS = {
78
- interceptors: [protocolVersionInterceptor, hannah_proto_1.compat_interceptor.compatVersionInterceptor],
77
+ interceptors: versioned_1.DEFAULT_INTERCEPTORS,
79
78
  };
80
79
  function formatAddress(host, port) {
81
80
  if (host.includes(':') && !host.startsWith('[')) {
@@ -256,11 +255,16 @@ class LogShipper {
256
255
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
257
256
  }
258
257
  }
259
- subscribeOnce(address, collectors, onMessage) {
258
+ async subscribeOnce(address, collectors, onMessage) {
259
+ // A new client per connection, so a reconnect probes the API generation again.
260
+ const clients = new versioned_1.VersionedClient(address, grpc.credentials.createInsecure(), {
261
+ clientOptions: CLIENT_OPTIONS,
262
+ warn: message => this.report('warn', message),
263
+ });
264
+ const client = await clients.resolve();
260
265
  return new Promise(resolve => {
261
- const client = new hannah_proto_1.hannah.HannahServiceClient(address, grpc.credentials.createInsecure(), CLIENT_OPTIONS);
262
266
  const call = client.subscribeInfrastructure({
263
- kinds: [hannah_proto_1.infrastructure.ServiceKind.SERVICE_KIND_LOG_COLLECTOR],
267
+ kinds: [infrastructure.ServiceKind.SERVICE_KIND_LOG_COLLECTOR],
264
268
  });
265
269
  this.discoveryCall = call;
266
270
  let settled = false;
@@ -270,7 +274,7 @@ class LogShipper {
270
274
  }
271
275
  settled = true;
272
276
  this.discoveryCall = null;
273
- client.close();
277
+ clients.close();
274
278
  resolve(reason);
275
279
  };
276
280
  call.on('data', (msg) => {
@@ -398,7 +402,7 @@ class LogShipper {
398
402
  exports.LogShipper = LogShipper;
399
403
  /** Applies `msg` to `collectors` and reports whether it was relevant. */
400
404
  function applyInfrastructure(msg, collectors) {
401
- const LOG_COLLECTOR = hannah_proto_1.infrastructure.ServiceKind.SERVICE_KIND_LOG_COLLECTOR;
405
+ const LOG_COLLECTOR = infrastructure.ServiceKind.SERVICE_KIND_LOG_COLLECTOR;
402
406
  if (msg.snapshot) {
403
407
  collectors.clear();
404
408
  for (const s of msg.snapshot.services) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m1kad0/hannah-grpc-lib",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared gRPC client code for Hannah components (log shipping to the Hannah log collector)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -25,11 +25,11 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@grpc/grpc-js": "^1.14.4",
28
- "@m1kad0/hannah-proto": "^4.5.0"
28
+ "@m1kad0/hannah-proto": "^4.6.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@grpc/grpc-js": "1.14.4",
32
- "@m1kad0/hannah-proto": "4.5.0",
32
+ "@m1kad0/hannah-proto": "4.6.0",
33
33
  "@types/node": "^22.0.0",
34
34
  "typescript": "~6.0.3"
35
35
  }