import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { credentials, loadPackageDefinition, type ChannelCredentials, } from "@grpc/grpc-js"; import protoLoader from "@grpc/proto-loader"; import { logger } from "../../shared/observability/logger.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROTO_ROOT = resolve( __dirname, "../../../../packages/shared-proto/proto", ); const LOADER_OPTIONS: protoLoader.Options = { keepCase: false, longs: String, enums: String, defaults: true, oneofs: true, }; /** * gRPC client 工厂(对齐 02-architecture-design.md §1.4 clients/grpc/)。 * * 使用 @grpc/proto-loader 动态加载 proto 文件, * 通过 @grpc/grpc-js 创建 Channel + Client 实例。 * * proto-loader keepCase: false → snake_case → camelCase * longs: String → int64 → string * enums: String → enum → string * * 返回的 client 为动态类型(proto-loader 不生成静态 TS 类型), * 调用方通过 `as unknown as SpecificClient` 断言为具体接口。 */ export function createGrpcClient(options: { protoFile: string; packageName: string; serviceName: string; target: string; }): TService { const protoPath = resolve(PROTO_ROOT, options.protoFile); logger.info( { protoFile: options.protoFile, packageName: options.packageName, serviceName: options.serviceName, target: options.target, }, "Creating gRPC client", ); const packageDefinition = protoLoader.loadSync(protoPath, LOADER_OPTIONS); const protoDescriptor = loadPackageDefinition(packageDefinition); // Navigate: protoDescriptor.next_edu_cloud.iam.v1.IamService const packages = options.packageName.split("."); let current: unknown = protoDescriptor; for (const pkg of packages) { current = (current as Record)[pkg]; if (current === undefined) { throw new Error( `gRPC package "${options.packageName}" not found in proto ${options.protoFile}`, ); } } const ServiceConstructor = (current as Record)[ options.serviceName ] as { new (target: string, creds: ChannelCredentials): TService; }; if (!ServiceConstructor) { throw new Error( `gRPC service "${options.serviceName}" not found in package "${options.packageName}"`, ); } // proto-loader 动态加载的 client 构造函数类型未知, // 从 unknown 断言为目标接口(项目规则允许从 unknown 转换) return new ServiceConstructor( options.target, credentials.createInsecure(), ) as unknown as TService; } /** * 将 gRPC callback 风格调用转为 Promise,同时记录 metrics。 * * proto-loader 加载的 client 方法签名为: * client.Method(request, metadata?, callback) * * 此辅助函数省略 metadata 参数,仅处理 request → callback → Promise。 * 需要 metadata 的调用(如传递 trace context)应直接操作 raw client。 */ export function callGrpc( client: unknown, method: string, request: TReq, serviceName: string, timeoutMs = 5000, ): Promise { const rawClient = client as Record; const fn = rawClient[method] as ( req: TReq, callback: (err: Error | null, res: TRes) => void, ) => void; if (typeof fn !== "function") { return Promise.reject( new Error(`gRPC method "${method}" not found on ${serviceName} client`), ); } return new Promise((resolvePromise, reject) => { const timer = setTimeout(() => { reject( new Error( `gRPC call ${serviceName}.${method} timed out after ${timeoutMs}ms`, ), ); }, timeoutMs); fn.call(rawClient, request, (err, res) => { clearTimeout(timer); if (err) { reject(err); } else { resolvePromise(res); } }); }); }