├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── proto-client ├── jest.config.js ├── package.json ├── src ├── ProtoClient.ts ├── ProtoRequest.ts ├── RequestError.ts ├── cli │ ├── FileBuilder.ts │ └── cli.ts ├── constants.ts ├── index.ts ├── interfaces.ts ├── untyped.ts └── util.ts ├── test ├── Cli.test.ts ├── MockServiceError.ts ├── ProtoClient.test.ts ├── ProtoRequest.test.ts ├── RequestError.test.ts ├── makeBidiStreamRequest.test.ts ├── makeClientStreamRequest.test.ts ├── makeRequest.test.ts ├── makeServerStreamRequest.test.ts ├── makeUnaryRequest.test.ts ├── metadata.test.ts ├── middleware.test.ts ├── pipe.test.ts ├── protos │ ├── customers.proto │ └── products.proto ├── retryOptions.test.ts ├── setup.ts ├── util.test.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | test/client -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x, 18.x, 19.x] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: yarn install 22 | - run: yarn test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | test/client/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v0.9 4 | 5 | - [#3](https://github.com/codenothing/proto-client/pull/3) isActive: Indicates if the request is active (started, but not finished) 6 | - [#3](https://github.com/codenothing/proto-client/pull/3) isWritable: Indicates if data can still be sent to the write stream 7 | - [#3](https://github.com/codenothing/proto-client/pull/3) isReadable: Indicates if the data is still coming from the read stream 8 | - [#4](https://github.com/codenothing/proto-client/pull/4) trailingMetadata: Metadata returned when the response is completed 9 | - [#5](https://github.com/codenothing/proto-client/pull/5) Moved from generic method lookup to lookupService->method 10 | 11 | ## v0.8 12 | 13 | - Initial implementation 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Corey Hart 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProtoClient 2 | 3 | A simple, typed gRPC Client with static code generation 4 | 5 | ```ts 6 | const client = new ProtoClient({ 7 | clientSettings: { endpoint: "0.0.0.0:8080" }, 8 | protoSettings: { files: ["protos/v1/customers.proto"] }, 9 | }); 10 | 11 | const { result, error } = await client.makeUnaryRequest( 12 | "v1.Customers.GetCustomer", 13 | { id: "github" } 14 | ); 15 | result; // Github Customer 16 | error; // Any error that may have occurred during the request 17 | ``` 18 | 19 | Reading data from a streamed response can be done by adding a read iterator 20 | 21 | ```ts 22 | await client.makeServerStreamRequest( 23 | "v1.Customers.FindCustomers", 24 | { name: "git*" }, 25 | async (row) => { 26 | row; // Incoming response row chunk 27 | } 28 | ); 29 | ``` 30 | 31 | Streaming data to a server can be done through a write sandbox 32 | 33 | ```ts 34 | const { result } = await client.makeClientStreamRequest( 35 | "v1.Customers.EditCustomer", 36 | async (write) => { 37 | await write({ id: "github", name: "Github" }); 38 | await write({ id: "npm", name: "NPM" }); 39 | } 40 | ); 41 | result.customers; // List of customers updated 42 | ``` 43 | 44 | And duplex streams combines both paradigms above into one 45 | 46 | ```ts 47 | await client.makeBidiStreamRequest( 48 | "v1.Customers.CreateCustomers", 49 | async (write) => { 50 | await write({ id: "github", name: "Github" }); 51 | await write({ id: "npm", name: "NPM" }); 52 | }, 53 | async (row) => { 54 | row; // Incoming response row chunk 55 | } 56 | ); 57 | ``` 58 | 59 | Requests can also be piped into other request streams 60 | 61 | ```ts 62 | const request = client.getServerStreamRequest("v1.Customers.FindCustomers", { 63 | name: "git*", 64 | }); 65 | 66 | await client.makeBidiStreamRequest( 67 | "v1.Customers.CreateCustomers", 68 | request.transform(async (customer) => { 69 | return { ...customer, isCopied: true }; 70 | }), 71 | async (row) => { 72 | row; // Incoming response row chunk 73 | } 74 | ); 75 | ``` 76 | 77 | ## Middleware 78 | 79 | The `ProtoClient` instance provides middleware injection to adjust requests before they are sent. A great use case would be adding authentication tokens to the request metadata in one location rather than at each line of code a request is made 80 | 81 | ```ts 82 | client.useMiddleware((request) => { 83 | request.metadata.set("authToken", "abc_123"); 84 | }); 85 | ``` 86 | 87 | ## Events 88 | 89 | Each request instance extends NodeJS's [EventEmitter](https://nodejs.org/api/events.html#class-eventemitter), allowing callers to hook into specific signal points during the process of a request. To extend the use case above, updates to authentication tokens can be passed back in Metadata and auto assigned in one location rather than at each line of code a request is made 90 | 91 | ```ts 92 | let authToken = ""; 93 | 94 | client.useMiddleware((request) => { 95 | if (authToken) { 96 | request.metadata.set("authToken", authToken); 97 | } 98 | 99 | request.on("response", () => { 100 | const token = request.responseMetadata?.get("updatedAuthToken"); 101 | if (token) { 102 | authToken = token[0]; 103 | } 104 | }); 105 | }); 106 | ``` 107 | 108 | ### List of Events 109 | 110 | - **ProtoRequest**: 111 | - `data`: Event emitted each time a response is received from the server (once for unary responses, every chunk for streamed responses) 112 | - `response`: Event emitted right before the first response is sent to the caller 113 | - `retry`: Event emitted right before a retry request is started 114 | - `aborted`: Event emitted when the request has been aborted 115 | - `error`: Event emitted when the request resolves with an error 116 | - `end`: Event emitted after the last response (or error) has been returned to the caller 117 | - **ProtoClient**: 118 | - `request`: Event emitted at creation of a request (before middleware is run) 119 | 120 | ## Multi Service Support 121 | 122 | For multi service architectures, `ProtoClient` comes with built-in support to configure which requests point to with service endpoint using a matching utility. This is purely configuration, and requires no changes to individual methods/requests 123 | 124 | ```ts 125 | client.configureClient({ 126 | endpoint: [ 127 | // Matching all rpc methods of the v1.Customers service to a specific service endpoint 128 | { 129 | address: "127.0.0.1:8081", 130 | match: "v1.Customers.*", 131 | }, 132 | 133 | // Matching all rpc methods of the v1.products.TransportationService service to a specific service endpoint 134 | { 135 | address: "127.0.0.1:8082", 136 | match: "v1.products.TransportationService.*", 137 | }, 138 | 139 | // Matching an entire namespace to a specific service endpoint 140 | { 141 | address: "127.0.0.1:8083", 142 | match: "v1.employees.*", 143 | }, 144 | ], 145 | }); 146 | ``` 147 | 148 | ## Automatic Retries 149 | 150 | Every request is wrapped in a retry function, allowing for fully customized handling of timeouts and retries. Can be configured at both the overall client, and individual request levels. 151 | 152 | - `retryCount`: Number of times to retry request. Defaults to none 153 | - `status`: Status code(s) request is allowed to retry on. Uses default list of status codes if not defined 154 | 155 | ## Static Code Generation 156 | 157 | Translates `.proto` files into functional API calls 158 | 159 | ``` 160 | proto-client [options] file1.proto file2.proto ... 161 | 162 | Options: 163 | --version Show version number 164 | -o, --output Output directory for compiled files 165 | -p, --path Adds a directory to the include path 166 | --keep-case Keeps field casing instead of converting to camel case 167 | --force-long Enforces the use of 'Long' for s-/u-/int64 and s-/fixed64 fields 168 | --force-number Enforces the use of 'number' for s-/u-/int64 and s-/fixed64 fields 169 | --help Show help 170 | ``` 171 | 172 | ### Example 173 | 174 | Given the following `customers.proto` file, running the `proto-client` command will generate a js/ts client scaffold for easy functional request making 175 | 176 | ```proto 177 | syntax = "proto3"; 178 | 179 | package v1; 180 | 181 | message Customer { 182 | string id = 1; 183 | string name = 2; 184 | } 185 | 186 | message GetCustomerRequest { 187 | string id = 1; 188 | } 189 | 190 | message FindCustomersRequest { 191 | string name = 1; 192 | } 193 | 194 | message CustomersResponse { 195 | repeated Customer customers = 1; 196 | } 197 | 198 | service Customers { 199 | rpc GetCustomer (GetCustomerRequest) returns (Customer); 200 | rpc FindCustomers (FindCustomersRequest) returns (stream Customer); 201 | rpc EditCustomer (stream Customer) returns (CustomersResponse); 202 | rpc CreateCustomers (stream Customer) returns (stream Customer); 203 | } 204 | ``` 205 | 206 | The `client.js` code generated: 207 | 208 | ```js 209 | module.exports.v1 = { 210 | Customers: { 211 | GetCustomer: async (data) => 212 | protoClient.makeUnaryRequest("v1.Customers.GetCustomer", data), 213 | 214 | CreateCustomers: async (writerSandbox, streamReader) => 215 | protoClient.CreateCustomers( 216 | "v1.Customers.CreateCustomers", 217 | writerSandbox, 218 | streamReader 219 | ), 220 | }, 221 | }; 222 | ``` 223 | 224 | To make calls: 225 | 226 | ```ts 227 | import { protoClient, v1 } from "output/client"; 228 | 229 | // Configuring service endpoint(s) 230 | protoClient.configureClient({ endpoint: "127.0.0.1:8080" }); 231 | 232 | // Unary Requests 233 | const { result } = await v1.Customers.GetCustomer({ id: "github" }); 234 | result; // Customer 235 | 236 | // Bidirectional Requests 237 | await v1.Customers.CreateCustomers( 238 | async (write) => { 239 | await write({ name: "github", action: "Github" }); 240 | await write({ name: "npm", action: "NPM" }); 241 | }, 242 | async (row) => { 243 | // ... each streamed row ... 244 | } 245 | ); 246 | ``` 247 | -------------------------------------------------------------------------------- /bin/proto-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("proto-client/dist/cli/cli.js").cli(); 3 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/test"], 3 | transform: { 4 | "^.+\\.ts$": "ts-jest", 5 | }, 6 | testEnvironment: "node", 7 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.ts$", 8 | moduleFileExtensions: ["ts", "js", "json", "node"], 9 | setupFilesAfterEnv: ["/test/setup.ts"], 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proto-client", 3 | "version": "0.9.0", 4 | "description": "A simple, typed gRPC Client with static code generation", 5 | "author": "Corey Hart ", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "bin": "./bin/proto-client", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/codenothing/proto-client.git" 13 | }, 14 | "scripts": { 15 | "clean": "rm -rf dist", 16 | "build": "yarn clean && tsc -p tsconfig.json", 17 | "lint": "eslint . --ext .ts", 18 | "pretest": "yarn build && yarn lint", 19 | "test": "jest --verbose --coverage --collectCoverageFrom=src/**", 20 | "prepublish": "yarn test" 21 | }, 22 | "keywords": [ 23 | "proto", 24 | "protobuf", 25 | "grpc", 26 | "client" 27 | ], 28 | "files": [ 29 | "dist", 30 | "bin", 31 | "package.json", 32 | "README.md", 33 | "LICENSE" 34 | ], 35 | "dependencies": { 36 | "@grpc/grpc-js": "^1.7.3", 37 | "@types/cli-color": "^2.0.2", 38 | "@types/yargs": "^17.0.13", 39 | "cli-color": "^2.0.3", 40 | "protobufjs": "^7.1.2", 41 | "protobufjs-cli": "^1.0.2", 42 | "yargs": "^17.6.2" 43 | }, 44 | "devDependencies": { 45 | "@grpc/proto-loader": "^0.7.3", 46 | "@types/jest": "^29.2.2", 47 | "@types/node": "^18.11.9", 48 | "@typescript-eslint/eslint-plugin": "^5.42.1", 49 | "@typescript-eslint/parser": "^5.42.1", 50 | "eslint": "^8.27.0", 51 | "jest": "^29.3.0", 52 | "ts-jest": "^29.0.3", 53 | "typescript": "^4.8.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ProtoRequest.ts: -------------------------------------------------------------------------------- 1 | import * as Protobuf from "protobufjs"; 2 | import { EventEmitter } from "events"; 3 | import { 4 | CallOptions, 5 | ClientDuplexStream, 6 | ClientReadableStream, 7 | ClientUnaryCall, 8 | ClientWritableStream, 9 | Metadata, 10 | ServiceError, 11 | status, 12 | StatusObject, 13 | } from "@grpc/grpc-js"; 14 | import type { ProtoClient } from "./ProtoClient"; 15 | import { RequestError } from "./RequestError"; 16 | import { DEFAULT_RETRY_STATUS_CODES, RequestMethodType } from "./constants"; 17 | import type { 18 | GenericRequestParams, 19 | RequestLifecycleTiming, 20 | RequestRetryOptions, 21 | StreamReader, 22 | StreamWriterSandbox, 23 | } from "./interfaces"; 24 | import { normalizeRetryOptions } from "./util"; 25 | import { promisify } from "util"; 26 | import type { Readable } from "stream"; 27 | 28 | /** 29 | * Internal reason for resolving request 30 | * @private 31 | */ 32 | const enum ResolutionType { 33 | Default, 34 | Abort, 35 | } 36 | 37 | // Protobuf Shortcuts 38 | type VerifyArgs = Parameters; 39 | type CreateArgs = Parameters; 40 | 41 | // Timing Shortcuts 42 | type TimingAttempt = Required; 43 | type ReadStreamTiming = TimingAttempt["read_stream"]; 44 | type ReadStreamTimingMessage = ReadStreamTiming["messages"][0]; 45 | type WriteStreamTiming = TimingAttempt["write_stream"]; 46 | type WriteStreamTimingMessage = WriteStreamTiming["messages"][0]; 47 | type PipeTiming = TimingAttempt["pipe_stream"]; 48 | type PipeTimingMessage = PipeTiming["messages"][0]; 49 | 50 | /** 51 | * Custom event typings 52 | */ 53 | export interface ProtoRequest { 54 | on( 55 | event: "data", 56 | listener: ( 57 | data: ResponseType, 58 | request: ProtoRequest 59 | ) => void 60 | ): this; 61 | on( 62 | event: "response", 63 | listener: (request: ProtoRequest) => void 64 | ): this; 65 | on( 66 | event: "retry", 67 | listener: (request: ProtoRequest) => void 68 | ): this; 69 | on( 70 | event: "aborted", 71 | listener: (request: ProtoRequest) => void 72 | ): this; 73 | on( 74 | event: "error", 75 | listener: ( 76 | error: Error, 77 | request: ProtoRequest 78 | ) => void 79 | ): this; 80 | on( 81 | event: "end", 82 | listener: (request: ProtoRequest) => void 83 | ): this; 84 | on( 85 | event: "close", 86 | listener: (request: ProtoRequest) => void 87 | ): this; 88 | 89 | once( 90 | event: "data", 91 | listener: ( 92 | data: ResponseType, 93 | request: ProtoRequest 94 | ) => void 95 | ): this; 96 | once( 97 | event: "response", 98 | listener: (request: ProtoRequest) => void 99 | ): this; 100 | once( 101 | event: "retry", 102 | listener: (request: ProtoRequest) => void 103 | ): this; 104 | once( 105 | event: "aborted", 106 | listener: (request: ProtoRequest) => void 107 | ): this; 108 | once( 109 | event: "error", 110 | listener: ( 111 | error: Error, 112 | request: ProtoRequest 113 | ) => void 114 | ): this; 115 | once( 116 | event: "end", 117 | listener: (request: ProtoRequest) => void 118 | ): this; 119 | once( 120 | event: "close", 121 | listener: (request: ProtoRequest) => void 122 | ): this; 123 | 124 | off( 125 | event: "data", 126 | listener: ( 127 | data: ResponseType, 128 | request: ProtoRequest 129 | ) => void 130 | ): this; 131 | off( 132 | event: "response", 133 | listener: (request: ProtoRequest) => void 134 | ): this; 135 | off( 136 | event: "retry", 137 | listener: (request: ProtoRequest) => void 138 | ): this; 139 | off( 140 | event: "aborted", 141 | listener: (request: ProtoRequest) => void 142 | ): this; 143 | off( 144 | event: "error", 145 | listener: ( 146 | error: Error, 147 | request: ProtoRequest 148 | ) => void 149 | ): this; 150 | off( 151 | event: "end", 152 | listener: (request: ProtoRequest) => void 153 | ): this; 154 | off( 155 | event: "close", 156 | listener: (request: ProtoRequest) => void 157 | ): this; 158 | 159 | emit( 160 | event: "data", 161 | data: ResponseType, 162 | request: ProtoRequest 163 | ): this; 164 | emit( 165 | eventName: "response", 166 | request: ProtoRequest 167 | ): boolean; 168 | emit( 169 | eventName: "retry", 170 | request: ProtoRequest 171 | ): boolean; 172 | emit( 173 | eventName: "aborted", 174 | request: ProtoRequest 175 | ): boolean; 176 | emit( 177 | eventName: "error", 178 | error: Error, 179 | request: ProtoRequest 180 | ): boolean; 181 | emit( 182 | eventName: "end", 183 | request: ProtoRequest 184 | ): boolean; 185 | emit( 186 | eventName: "close", 187 | request: ProtoRequest 188 | ): boolean; 189 | } 190 | 191 | /** 192 | * Individual gRPC request 193 | */ 194 | export class ProtoRequest extends EventEmitter { 195 | /** 196 | * Source lifecycle timing storage 197 | * @type {RequestLifecycleTiming} 198 | * @readonly 199 | */ 200 | public readonly timing: RequestLifecycleTiming = { 201 | started_at: Date.now(), 202 | middleware: { middleware: [] }, 203 | attempts: [], 204 | }; 205 | 206 | /** 207 | * Fully qualified path of the method for the request that can be used by protobufjs.lookup 208 | * @type {string} 209 | * @readonly 210 | */ 211 | public readonly method: string; 212 | 213 | /** 214 | * Generated request path 215 | * @type {string} 216 | * @readonly 217 | */ 218 | public readonly requestPath: string; 219 | 220 | /** 221 | * Request proto message type 222 | * @type {Protobuf.Method} 223 | * @readonly 224 | */ 225 | public readonly serviceMethod: Protobuf.Method; 226 | 227 | /** 228 | * Request proto message type 229 | * @type {Protobuf.Type} 230 | * @readonly 231 | */ 232 | public readonly requestType: Protobuf.Type; 233 | 234 | /** 235 | * Response proto message type 236 | * @type {Protobuf.Type} 237 | * @readonly 238 | */ 239 | public readonly responseType: Protobuf.Type; 240 | 241 | /** 242 | * Request method type 243 | * @type {RequestMethodType} 244 | * @readonly 245 | */ 246 | public readonly requestMethodType: RequestMethodType; 247 | 248 | /** 249 | * Request method type 250 | * @type {boolean} 251 | * @readonly 252 | */ 253 | public readonly isRequestStream: boolean; 254 | 255 | /** 256 | * Request method type 257 | * @type {boolean} 258 | * @readonly 259 | */ 260 | public readonly isResponseStream: boolean; 261 | 262 | /** 263 | * Data sent for unary requests 264 | * @type {RequestType | undefined} 265 | * @readonly 266 | */ 267 | public readonly requestData?: RequestType; 268 | 269 | /** 270 | * Pipes data from a stream to the request stream 271 | * @type {EventEmitter | Readable | undefined} 272 | * @readonly 273 | */ 274 | public readonly pipeStream?: EventEmitter | Readable; 275 | 276 | /** 277 | * Writer sandbox for request streams 278 | * @type {StreamWriterSandbox | undefined} 279 | * @readonly 280 | */ 281 | public readonly writerSandbox?: StreamWriterSandbox< 282 | RequestType, 283 | ResponseType 284 | >; 285 | 286 | /** 287 | * Read iterator for response streams 288 | * @type {StreamReader | undefined} 289 | * @readonly 290 | */ 291 | public readonly streamReader?: StreamReader; 292 | 293 | /** 294 | * Time in milliseconds before cancelling the request 295 | * @type {number} 296 | * @readonly 297 | */ 298 | public readonly timeout: number; 299 | 300 | /** 301 | * Configured retry options for this request 302 | * @type {RequestRetryOptions} 303 | * @readonly 304 | */ 305 | public readonly retryOptions: RequestRetryOptions; 306 | 307 | /** 308 | * AbortController tied to the request 309 | * @type {AbortController} 310 | * @readonly 311 | */ 312 | public readonly abortController: AbortController; 313 | 314 | /** 315 | * Metadata instance for the request 316 | * @type {Metadata} 317 | * @readonly 318 | */ 319 | public readonly metadata: Metadata; 320 | 321 | /** 322 | * Request specific options 323 | * @type {CallOptions} 324 | * @readonly 325 | */ 326 | public readonly callOptions: CallOptions; 327 | 328 | /** 329 | * Data response from the service, only valid for unary and client stream requests 330 | * @type {ResponseType} 331 | */ 332 | public result?: ResponseType; 333 | 334 | /** 335 | * Metadata returned from the service 336 | * @type {Metadata | undefined} 337 | */ 338 | public responseMetadata?: Metadata; 339 | 340 | /** 341 | * Metadata returned from the service 342 | * @type {StatusObject | undefined} 343 | */ 344 | public responseStatus?: StatusObject; 345 | 346 | /** 347 | * Number of retries made for this request 348 | * @type {number} 349 | */ 350 | public retries = 0; 351 | 352 | /** 353 | * References any error that may have occurred during the request 354 | * @type {Error} 355 | */ 356 | public error?: Error; 357 | 358 | /** 359 | * When retries are enabled, all errors will be stored here 360 | * @type {Error[]} 361 | * @readonly 362 | */ 363 | public readonly responseErrors: Error[] = []; 364 | 365 | /** 366 | * Internal reference to the parent client instance 367 | * @type {ProtoClient} 368 | * @readonly 369 | * @private 370 | */ 371 | private readonly client: ProtoClient; 372 | 373 | /** 374 | * Source lifecycle timing storage 375 | * @type {RequestLifecycleTiming} 376 | * @readonly 377 | * @private 378 | */ 379 | private timingAttempt: RequestLifecycleTiming["attempts"][0] = { 380 | started_at: Date.now(), 381 | }; 382 | 383 | /** 384 | * End promise queue while request is active 385 | * @type {Promise[]} 386 | * @private 387 | */ 388 | private endPromiseQueue: Array<{ 389 | resolve: (request: ProtoRequest) => void; 390 | reject: (error: Error) => void; 391 | }> = []; 392 | 393 | /** 394 | * Reference to the called stream from within the method 395 | * @type {ClientUnaryCall | ClientReadableStream | ClientWritableStream | ClientDuplexStream | null} 396 | * @private 397 | */ 398 | private stream: 399 | | ClientUnaryCall 400 | | ClientReadableStream 401 | | ClientWritableStream 402 | | ClientDuplexStream 403 | | null = null; 404 | 405 | /** 406 | * Internal reference to tracking activity of the request 407 | * @type {boolean} 408 | * @private 409 | */ 410 | private isRequestActive = true; 411 | 412 | /** 413 | * Internal representation of how the request was resolved 414 | * @type {ResolutionType | undefined} 415 | * @private 416 | */ 417 | private resolutionType?: ResolutionType; 418 | 419 | /** 420 | * Internal class for managing individual requests 421 | * @package 422 | * @private 423 | */ 424 | constructor( 425 | params: GenericRequestParams, 426 | client: ProtoClient 427 | ) { 428 | super(); 429 | 430 | this.method = params.method; 431 | this.client = client; 432 | this.requestData = params.data; 433 | this.pipeStream = params.pipeStream; 434 | this.writerSandbox = params.writerSandbox; 435 | this.streamReader = params.streamReader; 436 | 437 | // Break down the method to configure the path 438 | const methodParts = this.method.split(/\./g); 439 | const methodName = methodParts.pop(); 440 | const serviceName = methodParts.join("."); 441 | this.requestPath = `/${serviceName}/${methodName}`; 442 | 443 | // Validate service method exists 444 | const service = this.client.getRoot().lookupService(serviceName); 445 | this.serviceMethod = service.methods[methodName as string]; 446 | if (!this.serviceMethod) { 447 | throw new Error(`Method ${methodName} not found on ${serviceName}`); 448 | } 449 | 450 | // Mark stream types 451 | this.isRequestStream = !!this.serviceMethod.requestStream; 452 | this.isResponseStream = !!this.serviceMethod.responseStream; 453 | 454 | // Validate service method matches called function 455 | const expectedMethod = 456 | this.serviceMethod.requestStream && this.serviceMethod.responseStream 457 | ? RequestMethodType.BidiStreamRequest 458 | : this.serviceMethod.requestStream 459 | ? RequestMethodType.ClientStreamRequest 460 | : this.serviceMethod.responseStream 461 | ? RequestMethodType.ServerStreamRequest 462 | : RequestMethodType.UnaryRequest; 463 | 464 | // Only throw on expected method mismatch when defined 465 | if (params.requestMethodType) { 466 | this.requestMethodType = params.requestMethodType; 467 | if (expectedMethod !== params.requestMethodType) { 468 | throw new Error( 469 | `${params.requestMethodType} does not support method '${this.method}', use ${expectedMethod} instead` 470 | ); 471 | } 472 | } else { 473 | this.requestMethodType = expectedMethod; 474 | } 475 | 476 | // Assign the request and response types 477 | this.requestType = this.client 478 | .getRoot() 479 | .lookupType(this.serviceMethod.requestType); 480 | this.responseType = this.client 481 | .getRoot() 482 | .lookupType(this.serviceMethod.responseType); 483 | 484 | const { requestOptions } = params; 485 | 486 | // Use default request options 487 | if (!requestOptions) { 488 | this.abortController = new AbortController(); 489 | this.metadata = new Metadata(); 490 | this.callOptions = {}; 491 | this.timeout = this.client.clientSettings.timeout || 0; 492 | this.retryOptions = 493 | normalizeRetryOptions(this.client.clientSettings.retryOptions) || {}; 494 | } 495 | // Full options assignment 496 | else { 497 | this.abortController = 498 | requestOptions.abortController || new AbortController(); 499 | this.callOptions = requestOptions.callOptions || {}; 500 | this.timeout = 501 | requestOptions.timeout || this.client.clientSettings.timeout || 0; 502 | this.retryOptions = 503 | normalizeRetryOptions(requestOptions.retryOptions) || 504 | normalizeRetryOptions(this.client.clientSettings.retryOptions) || 505 | {}; 506 | 507 | // Metadata instance 508 | if ( 509 | requestOptions.metadata && 510 | requestOptions.metadata instanceof Metadata 511 | ) { 512 | this.metadata = requestOptions.metadata; 513 | } 514 | // Object containing metadata values 515 | else if (requestOptions.metadata) { 516 | this.metadata = new Metadata(); 517 | 518 | for (const key in requestOptions.metadata) { 519 | const value = requestOptions.metadata[key]; 520 | 521 | if (Array.isArray(value)) { 522 | value.forEach((item) => this.metadata.add(key, item)); 523 | } else { 524 | this.metadata.set(key, value); 525 | } 526 | } 527 | } 528 | // Metadata not passed in at all 529 | else { 530 | this.metadata = new Metadata(); 531 | } 532 | } 533 | } 534 | 535 | /** 536 | * Indicates if the request is active (started, but not finished) 537 | * @type {boolean} 538 | * @readonly 539 | */ 540 | public get isActive(): boolean { 541 | return this.isRequestActive; 542 | } 543 | 544 | /** 545 | * Indicates if data can still be sent to the write stream 546 | * @type {boolean} 547 | * @readonly 548 | */ 549 | public get isWritable(): boolean { 550 | return !!this.writeStream?.writable; 551 | } 552 | 553 | /** 554 | * Indicates if the data is still coming from the read stream 555 | * @type {boolean} 556 | * @readonly 557 | */ 558 | public get isReadable(): boolean { 559 | return !!this.readStream?.readable; 560 | } 561 | 562 | /** 563 | * Proxy to the trailing metadata returned at the end of the response 564 | * @type {Metadata | undefined} 565 | * @readonly 566 | */ 567 | public get trailingMetadata(): Metadata | undefined { 568 | return this.responseStatus?.metadata; 569 | } 570 | 571 | /** 572 | * Safety wrapper for typing stream as writable 573 | * @type {ClientWritableStream | ClientDuplexStream | undefined} 574 | * @readonly 575 | * @private 576 | */ 577 | private get writeStream(): 578 | | ClientWritableStream 579 | | ClientDuplexStream 580 | | undefined { 581 | if (this.isRequestStream && this.stream) { 582 | return this.stream as 583 | | ClientWritableStream 584 | | ClientDuplexStream; 585 | } 586 | } 587 | 588 | /** 589 | * Safety wrapper for typing stream as readable 590 | * @type {ClientReadableStream | ClientDuplexStream | undefined} 591 | * @readonly 592 | * @private 593 | */ 594 | private get readStream(): 595 | | ClientReadableStream 596 | | ClientDuplexStream 597 | | undefined { 598 | if (this.isResponseStream && this.stream) { 599 | return this.stream as 600 | | ClientReadableStream 601 | | ClientDuplexStream; 602 | } 603 | } 604 | 605 | /** 606 | * Internal use only, kicks off the request, adds abort listener and runs middleware 607 | * @package 608 | */ 609 | public start() { 610 | // Block duplicate calls to start(), can only happen through accidental usage 611 | if (this.timing.middleware.started_at) { 612 | throw new Error( 613 | `ProtoRequest.start is an internal method that should not be called` 614 | ); 615 | } 616 | 617 | // Listen for caller aborting of this request 618 | this.abortController.signal.addEventListener("abort", () => { 619 | this.stream?.cancel(); 620 | this.resolveRequest( 621 | ResolutionType.Abort, 622 | new RequestError(status.CANCELLED, this) 623 | ); 624 | }); 625 | 626 | // Run middleware before entering request loop 627 | this.runMiddleware() 628 | .then(() => { 629 | if (this.isActive) { 630 | this.makeRequest(); 631 | } 632 | }) 633 | .catch((e) => { 634 | let error: Error; 635 | 636 | if (e instanceof Error) { 637 | error = e; 638 | } else if (typeof e === "string") { 639 | error = new Error(e); 640 | } else { 641 | error = new Error(`Unknown Middleware Error`, { cause: e }); 642 | } 643 | 644 | this.resolveRequest(ResolutionType.Default, error); 645 | }); 646 | } 647 | 648 | /** 649 | * Runs any middleware attached to the client 650 | * @private 651 | */ 652 | private async runMiddleware() { 653 | this.timing.middleware.started_at = Date.now(); 654 | 655 | let timing: 656 | | RequestLifecycleTiming["middleware"]["middleware"][0] 657 | | undefined; 658 | try { 659 | for (const middleware of this.client.middleware) { 660 | // Time middleware while running it 661 | timing = { started_at: Date.now() }; 662 | this.timing.middleware.middleware.push(timing); 663 | await middleware(this, this.client); 664 | timing.ended_at = Date.now(); 665 | 666 | // Exit out if request was aborted in middleware 667 | if (!this.isActive) { 668 | return; 669 | } 670 | } 671 | this.timing.middleware.ended_at = Date.now(); 672 | } catch (e) { 673 | const now = Date.now(); 674 | 675 | // Mark timings 676 | this.timing.middleware.errored_at = now; 677 | this.timing.middleware.ended_at = now; 678 | if (timing) { 679 | timing.ended_at = now; 680 | } 681 | 682 | // Rethrow error 683 | throw e; 684 | } 685 | } 686 | 687 | /** 688 | * Retry-able requester starter method that sets up the 689 | * call stream with readers & writers 690 | * @private 691 | */ 692 | private makeRequest(): void { 693 | this.timingAttempt = { started_at: Date.now() }; 694 | this.timing.attempts.push(this.timingAttempt); 695 | 696 | // Reset reference data 697 | this.stream = null; 698 | this.resolutionType = undefined; 699 | this.error = undefined; 700 | this.responseMetadata = undefined; 701 | this.responseStatus = undefined; 702 | 703 | // Apply timeout to the deadline (if not already set) 704 | const callOptions = Object.create(this.callOptions) as CallOptions; 705 | if (this.timeout && callOptions.deadline === undefined) { 706 | callOptions.deadline = Date.now() + this.timeout; 707 | } 708 | 709 | // Data sanitation 710 | if (!this.isRequestStream && this.requestData) { 711 | const validationError = this.requestType.verify(this.requestData); 712 | if (validationError) { 713 | return this.resolveRequest( 714 | ResolutionType.Default, 715 | new RequestError(status.INVALID_ARGUMENT, this, validationError) 716 | ); 717 | } 718 | } 719 | 720 | // Unary Request 721 | if (this.requestMethodType === RequestMethodType.UnaryRequest) { 722 | this.stream = this.client 723 | .getClient(this) 724 | .makeUnaryRequest( 725 | this.requestPath, 726 | this.serializeRequest.bind(this), 727 | this.deserializeResponse.bind(this), 728 | (this.requestData || {}) as RequestType, 729 | this.metadata, 730 | callOptions, 731 | this.unaryCallback.bind(this, this.retries) 732 | ); 733 | } 734 | // Client Stream Request 735 | else if (this.requestMethodType === RequestMethodType.ClientStreamRequest) { 736 | this.stream = this.client 737 | .getClient(this) 738 | .makeClientStreamRequest( 739 | this.requestPath, 740 | this.serializeRequest.bind(this), 741 | this.deserializeResponse.bind(this), 742 | this.metadata, 743 | callOptions, 744 | this.unaryCallback.bind(this, this.retries) 745 | ); 746 | } 747 | // Server Stream Request 748 | else if (this.requestMethodType === RequestMethodType.ServerStreamRequest) { 749 | this.stream = this.client 750 | .getClient(this) 751 | .makeServerStreamRequest( 752 | this.requestPath, 753 | this.serializeRequest.bind(this), 754 | this.deserializeResponse.bind(this), 755 | (this.requestData || {}) as RequestType, 756 | this.metadata, 757 | callOptions 758 | ); 759 | } 760 | // Bidirectional Stream Request 761 | else { 762 | this.stream = this.client 763 | .getClient(this) 764 | .makeBidiStreamRequest( 765 | this.requestPath, 766 | this.serializeRequest.bind(this), 767 | this.deserializeResponse.bind(this), 768 | this.metadata, 769 | callOptions 770 | ); 771 | } 772 | 773 | // Bind response Metadata and Status to the request object 774 | const stream = this.stream; 775 | const onMetadata = (metadata: Metadata) => { 776 | if (stream === this.stream) { 777 | this.timingAttempt.metadata_received_at = Date.now(); 778 | this.responseMetadata = metadata; 779 | } 780 | }; 781 | const onStatus = (status: StatusObject) => { 782 | /** 783 | * NOTE: For unary callbacks (Unary/ClientStream requests), the status event 784 | * does not emit until after the callback is triggered. Given that the status 785 | * contains the trailing metadata, we need to assume that as long as a retry 786 | * request has not started (this.stream is null) means that this event belongs 787 | * to the most recently resolved (success or failure) attempt. 788 | * 789 | * https://github.com/grpc/grpc-node/blob/%40grpc/grpc-js%401.7.3/packages/grpc-js/src/client.ts#L360 790 | */ 791 | if (stream === this.stream || !this.stream) { 792 | this.timingAttempt.status_received_at = Date.now(); 793 | this.responseStatus = status; 794 | } 795 | 796 | // Status event comes in after the end event, so need 797 | // to keep these here 798 | stream.off("metadata", onMetadata); 799 | stream.off("status", onStatus); 800 | }; 801 | stream.once("metadata", onMetadata); 802 | stream.once("status", onStatus); 803 | 804 | // Setup read/write stream handling 805 | this.readFromServer(); 806 | this.proxyPipeStreamToServer(); 807 | this.writeToServer(); 808 | } 809 | 810 | /** 811 | * Callback binded to unary response methods 812 | * @param attempt Retry attempt number callback is binded for, to prevent old attempts from running 813 | * @param error Server error, if any 814 | * @param value Response data 815 | * @private 816 | */ 817 | private unaryCallback( 818 | attempt: number, 819 | error: ServiceError | null, 820 | value?: ResponseType 821 | ): void { 822 | if (!this.stream || attempt !== this.retries) { 823 | return; 824 | } 825 | 826 | this.result = value; 827 | this.emit("response", this); 828 | 829 | if (value) { 830 | this.emit("data", value, this); 831 | } 832 | 833 | this.resolveRequest(ResolutionType.Default, error || undefined); 834 | } 835 | 836 | /** 837 | * Listens for data on response streams 838 | * @private 839 | */ 840 | private readFromServer(): void { 841 | const stream = this.readStream; 842 | if (!this.isResponseStream || !stream) { 843 | return; 844 | } 845 | 846 | // Track timings 847 | const readTiming: ReadStreamTiming = { 848 | started_at: Date.now(), 849 | messages: [], 850 | }; 851 | this.timingAttempt.read_stream = readTiming; 852 | 853 | // Local refs for read stream lifecycle management 854 | let counter = 0; 855 | let responseRowIndex = 0; 856 | let ended = false; 857 | 858 | /** 859 | * Proxy each chunk of data from the service to the streamReader 860 | * 861 | * Request is not resolved until all streamReader operations 862 | * have completed 863 | */ 864 | const onData = (row: ResponseType) => { 865 | if (this.stream !== stream) { 866 | return removeListeners(); 867 | } 868 | 869 | // Track message times 870 | const messageTiming: ReadStreamTimingMessage = { 871 | received_at: Date.now(), 872 | }; 873 | readTiming.messages.push(messageTiming); 874 | 875 | // Signal first response from the server 876 | if (responseRowIndex === 0) { 877 | this.emit("response", this); 878 | } 879 | 880 | // Pipe to act like a readable stream 881 | this.emit("data", row, this); 882 | 883 | // Stream reader is optional 884 | if (!this.streamReader) { 885 | messageTiming.ended_at = Date.now(); 886 | return; 887 | } 888 | 889 | // Increment counters while processing 890 | counter++; 891 | this.streamReader(row, responseRowIndex++, this) 892 | .then(() => { 893 | messageTiming.ended_at = Date.now(); 894 | if (--counter < 1 && ended && this.stream === stream) { 895 | readTiming.last_processed_at = Date.now(); 896 | this.resolveRequest(ResolutionType.Default); 897 | } 898 | }) 899 | // Bubble any stream reader errors back to the caller 900 | .catch((e) => { 901 | messageTiming.ended_at = Date.now(); 902 | if (this.stream === stream) { 903 | readTiming.last_processed_at = Date.now(); 904 | this.stream.cancel(); 905 | this.resolveRequest(ResolutionType.Default, e); 906 | } 907 | }); 908 | }; 909 | 910 | // Any service error should kill the stream 911 | const onError = (e: Error) => { 912 | readTiming.errored_at ||= Date.now(); 913 | ended = true; 914 | removeListeners(); 915 | 916 | if (this.stream === stream) { 917 | this.resolveRequest(ResolutionType.Default, e); 918 | } 919 | }; 920 | 921 | // End event should trigger closure of request as long 922 | // as all stream reader operations are complete 923 | const onEnd = () => { 924 | readTiming.ended_at ||= Date.now(); 925 | ended = true; 926 | removeListeners(); 927 | 928 | if (this.stream === stream && counter < 1) { 929 | this.resolveRequest(ResolutionType.Default); 930 | } 931 | }; 932 | 933 | // Drop listeners once request completes 934 | const removeListeners = () => { 935 | stream.off("data", onData); 936 | stream.off("error", onError); 937 | stream.off("end", onEnd); 938 | }; 939 | 940 | // Start listening 941 | stream.on("data", onData); 942 | stream.on("error", onError); 943 | stream.on("end", onEnd); 944 | } 945 | 946 | /** 947 | * Pipes a readable like stream to the request stream 948 | * @private 949 | */ 950 | private proxyPipeStreamToServer(): void { 951 | const stream = this.writeStream; 952 | const pipeStream = this.pipeStream; 953 | if (!this.isRequestStream || !pipeStream || !stream) { 954 | return; 955 | } 956 | 957 | // Track timings 958 | const pipeTiming: PipeTiming = { 959 | started_at: Date.now(), 960 | messages: [], 961 | }; 962 | this.timingAttempt.pipe_stream = pipeTiming; 963 | 964 | // Transfer incoming data to the request stream 965 | const onData = (row: RequestType) => { 966 | // Track message times 967 | const messageTiming: PipeTimingMessage = { 968 | received_at: Date.now(), 969 | }; 970 | pipeTiming.messages.push(messageTiming); 971 | 972 | if (this.stream === stream) { 973 | stream.write(row, () => { 974 | messageTiming.written_at = Date.now(); 975 | }); 976 | } else { 977 | removeListeners(); 978 | } 979 | }; 980 | 981 | // Cancel the request if there is an error in the pipe 982 | const onError = (e: unknown) => { 983 | pipeTiming.errored_at ||= Date.now(); 984 | removeListeners(); 985 | if (this.stream === stream) { 986 | const error = 987 | e instanceof Error ? e : new Error("Pipe stream error", { cause: e }); 988 | 989 | this.stream.cancel(); 990 | this.resolveRequest(ResolutionType.Default, error); 991 | } 992 | }; 993 | 994 | // End the write stream when the pipe completes 995 | const onEnd = () => { 996 | pipeTiming.ended_at ||= Date.now(); 997 | removeListeners(); 998 | if (this.stream === stream) { 999 | stream.end(); 1000 | } 1001 | }; 1002 | 1003 | const removeListeners = () => { 1004 | pipeStream.off("data", onData); 1005 | pipeStream.off("error", onError); 1006 | pipeStream.off("end", onEnd); 1007 | pipeStream.off("close", onEnd); 1008 | }; 1009 | 1010 | pipeStream.on("data", onData); 1011 | pipeStream.on("error", onError); 1012 | pipeStream.on("end", onEnd); 1013 | pipeStream.on("close", onEnd); 1014 | } 1015 | 1016 | /** 1017 | * Opens the writer sandbox if it exists for request streams 1018 | * @private 1019 | */ 1020 | private writeToServer(): void { 1021 | const stream = this.writeStream; 1022 | if (!this.isRequestStream || !stream || this.pipeStream) { 1023 | return; 1024 | } else if (!this.writerSandbox) { 1025 | this.timingAttempt.write_stream = { 1026 | started_at: Date.now(), 1027 | ended_at: Date.now(), 1028 | messages: [], 1029 | }; 1030 | stream.end(); 1031 | return; 1032 | } 1033 | 1034 | // Track timings 1035 | const writeTiming: WriteStreamTiming = { 1036 | started_at: Date.now(), 1037 | messages: [], 1038 | }; 1039 | this.timingAttempt.write_stream = writeTiming; 1040 | 1041 | // Let the caller start safely writing to the stream 1042 | this.writerSandbox(async (data: RequestType, encoding?: string) => { 1043 | // Track message times 1044 | const messageTiming: WriteStreamTimingMessage = { 1045 | started_at: Date.now(), 1046 | }; 1047 | writeTiming.messages.push(messageTiming); 1048 | 1049 | // Verify stream is still writable 1050 | if (stream !== this.stream || !this.isWritable) { 1051 | throw new Error( 1052 | `The write stream has already closed for ${this.method}` 1053 | ); 1054 | } 1055 | 1056 | // Validate the message 1057 | const validationError = this.requestType.verify(data as VerifyArgs[0]); 1058 | if (validationError) { 1059 | throw new RequestError(status.INVALID_ARGUMENT, this, validationError); 1060 | } 1061 | 1062 | // Write message to the stream, waiting for the callback 1063 | // to return before resolving write 1064 | await promisify(stream.write.bind(stream, data, encoding))(); 1065 | messageTiming.written_at = Date.now(); 1066 | }, this) 1067 | .then(() => { 1068 | writeTiming.ended_at ||= Date.now(); 1069 | 1070 | if (stream === this.stream) { 1071 | stream.end(); 1072 | } 1073 | }) 1074 | .catch((e) => { 1075 | writeTiming.errored_at ||= Date.now(); 1076 | writeTiming.ended_at ||= Date.now(); 1077 | 1078 | if (stream === this.stream) { 1079 | this.stream.cancel(); 1080 | this.resolveRequest(ResolutionType.Default, e); 1081 | } 1082 | }); 1083 | } 1084 | 1085 | /** 1086 | * Marks stream as complete, resolving any queued promises 1087 | * @param error Any error that occurred during the request 1088 | * @param resolutionType Indicator for special error handling (like abort & timeout) 1089 | * @private 1090 | */ 1091 | private resolveRequest(resolutionType: ResolutionType, error?: Error): void { 1092 | if (!this.isRequestActive) { 1093 | return; 1094 | } 1095 | 1096 | const now = Date.now(); 1097 | this.stream = null; 1098 | this.resolutionType = resolutionType; 1099 | this.timingAttempt.ended_at ||= now; 1100 | 1101 | if (error) { 1102 | this.timingAttempt.errored_at ||= now; 1103 | this.responseErrors.push(error); 1104 | 1105 | // Allow for retries 1106 | if (this.canRetry((error as ServiceError).code, resolutionType)) { 1107 | this.retries++; 1108 | this.emit("retry", this); 1109 | return this.makeRequest(); 1110 | } 1111 | // End request with an error 1112 | else { 1113 | this.timing.errored_at ||= now; 1114 | this.timing.ended_at ||= now; 1115 | this.isRequestActive = false; 1116 | this.error = error; 1117 | 1118 | // Never reject if rejectOnError is disabled 1119 | if (this.client.clientSettings.rejectOnError === false) { 1120 | this.endPromiseQueue.forEach(({ resolve }) => resolve(this)); 1121 | } 1122 | // Always reject when rejectOnError is enabled 1123 | else if (this.client.clientSettings.rejectOnError === true) { 1124 | this.endPromiseQueue.forEach(({ reject }) => reject(error)); 1125 | } 1126 | // Abort handling 1127 | else if (resolutionType === ResolutionType.Abort) { 1128 | // Only reject if rejectOnAbort is enabled 1129 | if (this.client.clientSettings.rejectOnAbort === true) { 1130 | this.endPromiseQueue.forEach(({ reject }) => reject(error)); 1131 | } 1132 | 1133 | this.emit("aborted", this); 1134 | } 1135 | // Resolve everything else 1136 | else { 1137 | this.endPromiseQueue.forEach(({ resolve }) => resolve(this)); 1138 | } 1139 | 1140 | this.endPromiseQueue = []; 1141 | this.emit("error", error, this); 1142 | } 1143 | } 1144 | // Successful response 1145 | else { 1146 | this.timing.ended_at ||= now; 1147 | this.isRequestActive = false; 1148 | this.endPromiseQueue.forEach(({ resolve }) => resolve(this)); 1149 | this.endPromiseQueue = []; 1150 | this.emit("end", this); 1151 | } 1152 | 1153 | // Request fully resolved 1154 | this.emit("close", this); 1155 | } 1156 | 1157 | /** 1158 | * Serializing method for outgoing messages 1159 | * @param object Request object passed from the caller 1160 | * @private 1161 | */ 1162 | private serializeRequest(object: RequestType): Buffer { 1163 | return this.requestType 1164 | .encode(this.requestType.create(object as CreateArgs[0])) 1165 | .finish() as Buffer; 1166 | } 1167 | 1168 | /** 1169 | * Deserializing method for incoming messages 1170 | * @param buffer Buffer object response from the connection 1171 | * @private 1172 | */ 1173 | private deserializeResponse(buffer: Buffer): ResponseType { 1174 | return this.responseType.toObject( 1175 | this.responseType.decode(buffer), 1176 | this.client.protoConversionOptions 1177 | ) as ResponseType; 1178 | } 1179 | 1180 | /** 1181 | * Determines if this request can be retried on error 1182 | * @param code Status code of the current error 1183 | * @param resolutionType How the request resolved 1184 | * @private 1185 | */ 1186 | private canRetry( 1187 | code: status | undefined, 1188 | resolutionType: ResolutionType 1189 | ): boolean { 1190 | // Request aborts can't be retried 1191 | if (resolutionType === ResolutionType.Abort) { 1192 | return false; 1193 | } 1194 | 1195 | // Check for custom retry codes, otherwise fallback to default codes 1196 | let approvedCodes: status[] = []; 1197 | if (this.retryOptions.status) { 1198 | approvedCodes = Array.isArray(this.retryOptions.status) 1199 | ? this.retryOptions.status 1200 | : [this.retryOptions.status]; 1201 | } else { 1202 | approvedCodes = DEFAULT_RETRY_STATUS_CODES; 1203 | } 1204 | 1205 | // Check all parameters to see if request can be retried 1206 | return !!( 1207 | !this.abortController.signal.aborted && 1208 | this.retryOptions.retryCount && 1209 | this.retries < this.retryOptions.retryCount && 1210 | code !== undefined && 1211 | approvedCodes.includes(code) && 1212 | // Piped streams can not be retried 1213 | !this.pipeStream 1214 | ); 1215 | } 1216 | 1217 | /** 1218 | * Pipes response data from this request through the transformer and out into 1219 | * a new event emitter. This can be useful for piping one client request to 1220 | * another with an async transformer on each message 1221 | * @param {DataTransformer} transformer Async function for transforming data 1222 | * @returns {EventEmitter} A new event emitter instance 1223 | */ 1224 | public transform( 1225 | transformer: (data: ResponseType) => Promise 1226 | ): EventEmitter { 1227 | const emitter = new EventEmitter(); 1228 | 1229 | // Local refs 1230 | let counter = 0; 1231 | let ended = false; 1232 | let finished = false; 1233 | 1234 | // Process each data chunk through the transformer 1235 | const onData = (data: ResponseType) => { 1236 | counter++; 1237 | transformer(data) 1238 | .then((output) => { 1239 | if (finished) { 1240 | return; 1241 | } 1242 | 1243 | emitter.emit("data", output); 1244 | if (--counter < 1 && ended) { 1245 | finished = true; 1246 | emitter.emit("end"); 1247 | } 1248 | }) 1249 | .catch((e) => { 1250 | if (!finished) { 1251 | finished = true; 1252 | emitter.emit("error", e); 1253 | } 1254 | }); 1255 | }; 1256 | 1257 | // Stop processing on error 1258 | const onError = (e: Error) => { 1259 | remoteEvents(); 1260 | finished = true; 1261 | emitter.emit("error", e); 1262 | }; 1263 | 1264 | // Keep track of when there are no more data events incoming, 1265 | // but don't signal end on the emitter until after all transforms 1266 | // have completed 1267 | const onEnd = () => { 1268 | remoteEvents(); 1269 | ended = true; 1270 | if (counter < 1) { 1271 | finished = true; 1272 | emitter.emit("end"); 1273 | } 1274 | }; 1275 | 1276 | // Normalized event removal 1277 | const remoteEvents = () => { 1278 | this.off("data", onData); 1279 | this.off("error", onError); 1280 | this.off("end", onEnd); 1281 | }; 1282 | 1283 | // Bind stream events 1284 | this.on("data", onData); 1285 | this.on("error", onError); 1286 | this.on("end", onEnd); 1287 | 1288 | return emitter; 1289 | } 1290 | 1291 | /** 1292 | * Aborts the request if it is still active 1293 | */ 1294 | public abort(): void { 1295 | if (!this.abortController.signal.aborted) { 1296 | this.abortController.abort(); 1297 | } 1298 | } 1299 | 1300 | /** 1301 | * Enhanced "end" event listener. Adds promise to a queue, waiting for the request 1302 | * to complete, rejecting on failures only if configured to. If the request is already 1303 | * complete, the result is returned (or exception raised) 1304 | * @returns {ProtoRequest} This ProtoRequest instance 1305 | */ 1306 | public async waitForEnd(): Promise> { 1307 | return new Promise>( 1308 | (resolve, reject) => { 1309 | // Request is still active, wait for it to complete 1310 | if (this.isActive) { 1311 | this.endPromiseQueue.push({ resolve, reject }); 1312 | } 1313 | // Error handling 1314 | else if (this.error) { 1315 | // Never reject if rejectOnError is disabled 1316 | if (this.client.clientSettings.rejectOnError === false) { 1317 | resolve(this); 1318 | } 1319 | // Always reject when rejectOnError is enabled 1320 | else if (this.client.clientSettings.rejectOnError === true) { 1321 | reject(this.error); 1322 | } 1323 | // Abort handling 1324 | else if (this.resolutionType === ResolutionType.Abort) { 1325 | // Only reject if rejectOnAbort is enabled 1326 | if (this.client.clientSettings.rejectOnAbort === true) { 1327 | reject(this.error); 1328 | } 1329 | } 1330 | // Nothing configured, default resolve 1331 | else { 1332 | resolve(this); 1333 | } 1334 | } 1335 | // Request already completed successfully 1336 | else { 1337 | resolve(this); 1338 | } 1339 | } 1340 | ); 1341 | } 1342 | 1343 | /** 1344 | * Builds a human readable description of results for this request 1345 | */ 1346 | public toString(): string { 1347 | const now = Date.now(); 1348 | 1349 | // Shortcuts 1350 | const methodType = this.requestMethodType.replace(/^make/, ""); 1351 | const statusDisplay = this.isActive 1352 | ? "ACTIVE" 1353 | : this.error 1354 | ? status[(this.error as RequestError).code || status.UNKNOWN] 1355 | : "OK"; 1356 | const diff = (this.timing.ended_at || now) - this.timing.started_at; 1357 | const middlewareRange = this.timing.middleware.started_at 1358 | ? (this.timing.middleware.ended_at || now) - 1359 | this.timing.middleware.started_at 1360 | : 0; 1361 | 1362 | // Compile total time spent in each action 1363 | let writeRange: number | undefined; 1364 | let pipeRange: number | undefined; 1365 | let readRange: number | undefined; 1366 | this.timing.attempts.forEach((attempt) => { 1367 | if (attempt.write_stream) { 1368 | writeRange ||= 0; 1369 | writeRange += 1370 | (attempt.write_stream.ended_at || now) - 1371 | attempt.write_stream.started_at; 1372 | } 1373 | if (attempt.pipe_stream) { 1374 | pipeRange ||= 0; 1375 | pipeRange += 1376 | (attempt.pipe_stream.ended_at || now) - 1377 | attempt.pipe_stream.started_at; 1378 | } 1379 | if (attempt.read_stream) { 1380 | readRange ||= 0; 1381 | readRange += 1382 | (attempt.read_stream.ended_at || now) - 1383 | attempt.read_stream.started_at; 1384 | } 1385 | }); 1386 | 1387 | return [ 1388 | `[${methodType}:${statusDisplay}]`, 1389 | `"${this.method}"`, 1390 | `(${diff}ms)`, 1391 | `attempts:${this.timing.attempts.length}`, 1392 | middlewareRange ? `middleware:${middlewareRange}ms` : null, 1393 | writeRange !== undefined ? `writes:${writeRange}ms` : null, 1394 | pipeRange !== undefined ? `pipe:${pipeRange}ms` : null, 1395 | readRange !== undefined ? `reads:${readRange}ms` : null, 1396 | ] 1397 | .filter(Boolean) 1398 | .join(" "); 1399 | } 1400 | } 1401 | -------------------------------------------------------------------------------- /src/RequestError.ts: -------------------------------------------------------------------------------- 1 | import { Metadata, ServiceError, status } from "@grpc/grpc-js"; 2 | import type { UntypedProtoRequest } from "./untyped"; 3 | 4 | /** 5 | * Utility class to create errors for gRPC requests 6 | */ 7 | export class RequestError extends Error implements ServiceError { 8 | /** 9 | * Error status code 10 | * @type {status} 11 | * @readonly 12 | */ 13 | public readonly code: status; 14 | 15 | /** 16 | * Error details 17 | * @type {string} 18 | * @readonly 19 | */ 20 | public readonly details: string; 21 | 22 | /** 23 | * Request metadata 24 | * @type {Metadata} 25 | * @readonly 26 | */ 27 | public readonly metadata: Metadata; 28 | 29 | /** 30 | * Creates custom error for gRPC requests 31 | * @param {status} code Status code representing the error 32 | * @param {ProtoRequest} request ProtoRequest instance which triggered this error 33 | * @param {string} [details] Error details (message) 34 | */ 35 | constructor(code: status, request: UntypedProtoRequest, details?: string) { 36 | if (!details) { 37 | if (code === status.CANCELLED) { 38 | details = `Cancelled ${request.requestMethodType} for '${request.method}'`; 39 | } else { 40 | details = `${code} ${status[code]}: ${request.requestMethodType} for '${request.method}'`; 41 | } 42 | } 43 | super(details); 44 | 45 | this.code = code; 46 | this.details = details; 47 | this.metadata = request.metadata; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/cli/FileBuilder.ts: -------------------------------------------------------------------------------- 1 | import { promises } from "fs"; 2 | import color from "cli-color"; 3 | 4 | export interface AddMethodsParams { 5 | methodType: string; 6 | namespace: string; 7 | returnType: string; 8 | args: Record; 9 | combos: string[][]; 10 | } 11 | 12 | /** 13 | * File building util used in cli 14 | * @private 15 | * @package 16 | */ 17 | export class FileBuilder { 18 | public indentCharacter = "\t"; 19 | public filePath = ""; 20 | public lines: string[] = []; 21 | public indentation: string[] = []; 22 | 23 | constructor(filePath: string) { 24 | this.filePath = filePath; 25 | } 26 | 27 | public get contents() { 28 | return this.lines.join("\n"); 29 | } 30 | 31 | public push(...lines: string[]) { 32 | const indent = this.indentation.join(""); 33 | lines.forEach((line) => this.lines.push(indent + line)); 34 | } 35 | 36 | public pushWithIndent(...lines: string[]) { 37 | this.indent(); 38 | this.push(...lines); 39 | this.deindent(); 40 | } 41 | 42 | public addTypedMethods({ 43 | methodType, 44 | namespace, 45 | returnType, 46 | args, 47 | combos, 48 | }: AddMethodsParams) { 49 | const makeStrings: string[] = []; 50 | const getStrings: string[] = []; 51 | 52 | for (const combo of combos) { 53 | const argDocs = combo.map((arg) => { 54 | const { shortType, desc } = args[arg]; 55 | return ` * @param {${shortType}} ${arg} ${desc}`; 56 | }); 57 | 58 | // Add doc string to make methods 59 | makeStrings.push( 60 | `/**`, 61 | ` * ${methodType} Request to ${namespace}`, 62 | ...argDocs, 63 | ` */` 64 | ); 65 | 66 | // Add doc string to get methods 67 | getStrings.push( 68 | `/**`, 69 | ` * Starts ${methodType} Request to ${namespace}`, 70 | ...argDocs, 71 | ` */` 72 | ); 73 | 74 | // Put each argument on it's own line when there are more than one 75 | const argParams = combo.map((arg) => `${arg}: ${args[arg].type}`); 76 | if (argParams.length > 1) { 77 | makeStrings.push(`(`); 78 | getStrings.push(`get(`); 79 | 80 | argParams.forEach((row, index) => { 81 | const line = `${this.indentCharacter}${row}${ 82 | index === argParams.length ? "" : "," 83 | }`; 84 | 85 | makeStrings.push(line); 86 | getStrings.push(line); 87 | }); 88 | 89 | makeStrings.push(`): Promise<${returnType}>;`); 90 | getStrings.push(`): ${returnType};`); 91 | } else { 92 | makeStrings.push(`(${argParams}): Promise<${returnType}>;`); 93 | getStrings.push(`get(${argParams}): ${returnType};`); 94 | } 95 | } 96 | 97 | this.push(...makeStrings, ...getStrings); 98 | } 99 | 100 | public indent() { 101 | this.indentation.push(this.indentCharacter); 102 | } 103 | 104 | public deindent() { 105 | if (this.indentation.length) { 106 | this.indentation.pop(); 107 | } 108 | } 109 | 110 | public newline() { 111 | this.lines.push(``); 112 | } 113 | 114 | public async write() { 115 | const filename = this.filePath.split(/\//g).pop(); 116 | 117 | try { 118 | await promises.writeFile(this.filePath, this.contents, `utf-8`); 119 | console.log(color.green(`✔ ${filename}`)); 120 | } catch (e) { 121 | console.log(color.red(`✖ Failed to write ${filename}`)); 122 | console.error(e); 123 | process.exit(1); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | import { pbjs, pbts } from "protobufjs-cli"; 2 | import yargs from "yargs"; 3 | import color from "cli-color"; 4 | import { hideBin } from "yargs/helpers"; 5 | import { promises } from "fs"; 6 | import { promisify } from "util"; 7 | import { AddMethodsParams, FileBuilder } from "./FileBuilder"; 8 | 9 | interface Method { 10 | requestType: string; 11 | responseType: string; 12 | requestStream?: boolean; 13 | responseStream?: boolean; 14 | } 15 | 16 | interface ServiceMethodChain { 17 | [key: string]: { 18 | namespace: string; 19 | methods: Record; 20 | nested: ServiceMethodChain; 21 | }; 22 | } 23 | 24 | interface RawProtos { 25 | methods?: { 26 | [key: string]: Method; 27 | }; 28 | nested?: { 29 | [key: string]: RawProtos; 30 | }; 31 | } 32 | 33 | /** 34 | * Cli runner to building typed endpoints for protos defined 35 | * @private 36 | * @package 37 | */ 38 | export class ProtoCli { 39 | public protoPackagePath = "proto-client"; 40 | public protoFiles: string[] = []; 41 | public rawProtos: RawProtos = {}; 42 | public serviceMethodChain: ServiceMethodChain = {}; 43 | 44 | // Cli Args used for parsing 45 | public argv: string[]; 46 | 47 | // Parsed cli options 48 | public outputDirectory = ""; 49 | public keepCase = false; 50 | public forceLong = false; 51 | public forceNumber = false; 52 | public includePaths: string[] = []; 53 | 54 | constructor(argv: string[]) { 55 | this.argv = argv; 56 | } 57 | 58 | // Number conversion argument for pbjs 59 | public get numberConversion(): string { 60 | return this.forceLong 61 | ? `--force-long` 62 | : this.forceNumber 63 | ? "--force-number" 64 | : ""; 65 | } 66 | 67 | // Adds directory include paths to arguments lists 68 | public get includeDirectoryArgs(): string[] { 69 | const args: string[] = []; 70 | this.includePaths.forEach((path) => args.push("-p", path)); 71 | return args; 72 | } 73 | 74 | // Centralized runner 75 | public async run() { 76 | const now = Date.now(); 77 | 78 | // Parse CLI Args 79 | await this.parseArgv(); 80 | 81 | // Build out files using protobufjs-cli 82 | await this.writeRawProtos(); 83 | await this.writeProtosJs(); 84 | await this.writeProtosTypes(); 85 | 86 | // Compile client method shortcuts 87 | this.compileServiceMethods(this.rawProtos, []); 88 | await this.writeClientJs(); 89 | await this.writeClientTypes(); 90 | 91 | // Log the result 92 | console.log( 93 | `\n`, 94 | color.green(`All files generated in ${Date.now() - now}ms`) 95 | ); 96 | } 97 | 98 | // Logging error and exiting the process 99 | public exitWithError(message: string, error?: Error | unknown) { 100 | console.log(color.red(message)); 101 | if (error) { 102 | console.error(error); 103 | } 104 | process.exit(1); 105 | } 106 | 107 | // Logging filename with check mark 108 | public logFileWritten(filename: string) { 109 | console.log(color.green(`✔ ${filename}`)); 110 | } 111 | 112 | // Parsing CLI arguments 113 | public async parseArgv() { 114 | const argv = await yargs(hideBin(this.argv)) 115 | .usage("proto-client [options] file1.proto file2.proto ...") 116 | .option("output", { 117 | alias: "o", 118 | describe: "Output directory for compiled files", 119 | type: "string", 120 | }) 121 | .option("path", { 122 | alias: "p", 123 | describe: "Adds a directory to the include path", 124 | type: "string", 125 | array: true, 126 | }) 127 | .option("keep-case", { 128 | describe: "Keeps field casing instead of converting to camel case", 129 | type: "boolean", 130 | }) 131 | .option("force-long", { 132 | describe: 133 | "Enforces the use of 'Long' for s-/u-/int64 and s-/fixed64 fields", 134 | type: "boolean", 135 | }) 136 | .option("force-number", { 137 | describe: 138 | "Enforces the use of 'number' for s-/u-/int64 and s-/fixed64 fields", 139 | type: "boolean", 140 | }) 141 | .help().argv; 142 | 143 | this.outputDirectory = argv.output || process.cwd(); 144 | this.keepCase = argv.keepCase || false; 145 | this.forceLong = argv.forceLong || false; 146 | this.forceNumber = argv.forceNumber || false; 147 | this.includePaths = argv.path || []; 148 | this.protoFiles = argv._ as string[]; 149 | 150 | try { 151 | const stat = await promises.stat(this.outputDirectory); 152 | if (!stat.isDirectory()) { 153 | this.exitWithError( 154 | `Output path '${this.outputDirectory}' is not a directory` 155 | ); 156 | } 157 | } catch (e) { 158 | this.exitWithError( 159 | `Output path '${this.outputDirectory}' could not be found`, 160 | e 161 | ); 162 | } 163 | } 164 | 165 | // Writing json representation of the raw protos 166 | public async writeRawProtos() { 167 | try { 168 | await promisify(pbjs.main.bind(pbjs))( 169 | [ 170 | "-t", 171 | "json", 172 | this.keepCase ? "--keep-case" : "", 173 | ...this.includeDirectoryArgs, 174 | "-o", 175 | `${this.outputDirectory}/raw-protos.json`, 176 | ...this.protoFiles, 177 | ].filter(Boolean) 178 | ); 179 | this.logFileWritten(`raw-protos.json`); 180 | } catch (e) { 181 | this.exitWithError( 182 | `Failed to write raw-protos.json: ${(e as Error).message}`, 183 | e 184 | ); 185 | } 186 | 187 | try { 188 | const contents = await promises.readFile( 189 | `${this.outputDirectory}/raw-protos.json`, 190 | `utf-8` 191 | ); 192 | this.rawProtos = JSON.parse(contents); 193 | } catch (e) { 194 | this.exitWithError( 195 | `Unable to read raw-protos.json: ${(e as Error).message}`, 196 | e 197 | ); 198 | } 199 | } 200 | 201 | // Write JS converted protos 202 | public async writeProtosJs() { 203 | try { 204 | await promisify(pbjs.main.bind(pbjs))( 205 | [ 206 | `-t`, 207 | `static-module`, 208 | `-w`, 209 | `commonjs`, 210 | this.keepCase ? "--keep-case" : "", 211 | this.numberConversion, 212 | ...this.includeDirectoryArgs, 213 | `--no-create`, 214 | `--no-encode`, 215 | `--no-decode`, 216 | `--no-verify`, 217 | `--no-convert`, 218 | `--no-delimited`, 219 | `--es6`, 220 | "-o", 221 | `${this.outputDirectory}/protos.js`, 222 | ...this.protoFiles, 223 | ].filter(Boolean) 224 | ); 225 | this.logFileWritten(`protos.js`); 226 | } catch (e) { 227 | this.exitWithError( 228 | `Failed to write protos.js: ${(e as Error).message}`, 229 | e 230 | ); 231 | } 232 | } 233 | 234 | // Write out types of the js protos 235 | public async writeProtosTypes() { 236 | try { 237 | await promisify(pbts.main.bind(pbts))([ 238 | "-o", 239 | `${this.outputDirectory}/protos.d.ts`, 240 | `${this.outputDirectory}/protos.js`, 241 | ]); 242 | this.logFileWritten(`protos.d.ts`); 243 | } catch (e) { 244 | this.exitWithError( 245 | `Failed to write protos.d.ts: ${(e as Error).message}`, 246 | e 247 | ); 248 | } 249 | } 250 | 251 | // Scans for all service methods from the proto files provided 252 | public compileServiceMethods(rawProto: RawProtos, chain: string[]) { 253 | if (!rawProto.nested) { 254 | return; 255 | } 256 | 257 | for (const key in rawProto.nested) { 258 | const subProto = rawProto.nested[key]; 259 | 260 | if (subProto.methods) { 261 | // Prefill the service chain 262 | let serviceMethodChain = this.serviceMethodChain; 263 | chain.forEach((name) => { 264 | serviceMethodChain[name] = serviceMethodChain[name] || { 265 | nested: {}, 266 | methods: {}, 267 | namespace: "", 268 | }; 269 | serviceMethodChain = serviceMethodChain[name].nested; 270 | }); 271 | 272 | // Default the current service chain 273 | const service = serviceMethodChain[key] || { 274 | nested: {}, 275 | methods: {}, 276 | namespace: [...chain, key].join("."), 277 | }; 278 | serviceMethodChain[key] = service; 279 | 280 | // Attach request methods 281 | for (const name in subProto.methods) { 282 | const method = subProto.methods[name]; 283 | service.methods[name] = { 284 | requestType: 285 | subProto.nested && subProto.nested[method.requestType] 286 | ? `${[...chain, key].join(".")}.I${method.requestType}` 287 | : `${chain.join(".")}.I${method.requestType}`, 288 | responseType: 289 | subProto.nested && subProto.nested[method.responseType] 290 | ? `${[...chain, key].join(".")}.I${method.responseType}` 291 | : `${chain.join(".")}.I${method.responseType}`, 292 | requestStream: method.requestStream, 293 | responseStream: method.responseStream, 294 | namespace: [...chain, key, name].join("."), 295 | }; 296 | } 297 | } 298 | 299 | this.compileServiceMethods(subProto, [...chain, key]); 300 | } 301 | } 302 | 303 | // Builds out client JS shortcuts 304 | public async writeClientJs() { 305 | const builder = new FileBuilder(`${this.outputDirectory}/client.js`); 306 | builder.push( 307 | `const { ProtoClient } = require("${this.protoPackagePath}");`, 308 | ``, 309 | `/**`, 310 | ` * Configured protoClient used for client shortcuts`, 311 | ` */`, 312 | `const protoClient = module.exports.protoClient = new ProtoClient({`, 313 | ` protoSettings: {`, 314 | ` files: __dirname + "/raw-protos.json",`, 315 | ` parseOptions: {`, 316 | ` keepCase: ${this.keepCase},`, 317 | ` },`, 318 | ` conversionOptions: {`, 319 | ` longs: ${this.forceNumber ? "Number" : "undefined"},`, 320 | ` },`, 321 | ` }`, 322 | `});` 323 | ); 324 | 325 | for (const chainName in this.serviceMethodChain) { 326 | builder.newline(); 327 | builder.push( 328 | `/**`, 329 | ` * ${chainName} Package`, 330 | ` * @namespace ${chainName}`, 331 | ` */`, 332 | `module.exports.${chainName} = {` 333 | ); 334 | builder.indent(); 335 | this.clientJsMethodChain( 336 | this.serviceMethodChain[chainName].nested, 337 | builder 338 | ); 339 | builder.deindent(); 340 | builder.push(`};`); 341 | } 342 | 343 | await builder.write(); 344 | } 345 | 346 | // Nested looping to build namespaces and service methods 347 | public clientJsMethodChain( 348 | serviceChain: ServiceMethodChain, 349 | builder: FileBuilder 350 | ) { 351 | for (const [chainName, subChain] of Object.entries(serviceChain)) { 352 | // Chain entry is a service 353 | if (Object.keys(subChain.methods).length) { 354 | builder.push( 355 | `/**`, 356 | ` * ${chainName} Service`, 357 | ` * @namespace ${chainName}`, 358 | ` */`, 359 | `${chainName}: {` 360 | ); 361 | builder.indent(); 362 | 363 | // Attach all methods 364 | for (const [methodName, method] of Object.entries(subChain.methods)) { 365 | const reqResType = `${method.requestType}, ${method.responseType}`; 366 | const returnType = `ProtoRequest<${reqResType}>`; 367 | 368 | // Open method wrapper 369 | builder.push( 370 | `/**`, 371 | ` * Request generation for ${methodName}`, 372 | ` */`, 373 | `${methodName}: (() => {` 374 | ); 375 | 376 | // Bidirectional 377 | if (method.requestStream && method.responseStream) { 378 | builder.pushWithIndent( 379 | `/**`, 380 | ` * Bidirectional Request to ${method.namespace}`, 381 | ` * @param {StreamWriterSandbox<${method.requestType}> | Readable | EventEmitter} [writerSandbox] Optional async supported callback for writing data to the open stream`, 382 | ` * @param {StreamReader<${method.responseType}>} [streamReader] Optional iteration function that will be called on every response chunk`, 383 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 384 | ` * @returns {Promise<${returnType}>} Request instance`, 385 | ` */`, 386 | `const ${methodName} = async (writerSandbox, streamReader, requestOptions) =>`, 387 | `${builder.indentCharacter}protoClient.makeBidiStreamRequest("${method.namespace}", writerSandbox, streamReader, requestOptions);`, 388 | `/**`, 389 | ` * Triggers bidirectional request to ${method.namespace}, returning the ProtoRequest instance`, 390 | ` * @param {StreamWriterSandbox<${method.requestType}> | Readable | EventEmitter} [writerSandbox] Optional async supported callback for writing data to the open stream`, 391 | ` * @param {StreamReader<${method.responseType}>} [streamReader] Optional iteration function that will be called on every response chunk`, 392 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 393 | ` * @returns {${returnType}} Request instance`, 394 | ` */`, 395 | `${methodName}.get = (writerSandbox, streamReader, requestOptions) =>`, 396 | `${builder.indentCharacter}protoClient.getBidiStreamRequest("${method.namespace}", writerSandbox, streamReader, requestOptions);`, 397 | `return ${methodName};` 398 | ); 399 | } 400 | // Server Stream 401 | else if (method.responseStream) { 402 | builder.pushWithIndent( 403 | `/**`, 404 | ` * Server Stream Request to ${method.namespace}`, 405 | ` * @param {${method.requestType} | StreamReader<${method.responseType}>} [data] Optional data to be sent as part of the request`, 406 | ` * @param {StreamReader<${method.responseType}>} [streamReader] Optional iteration function that will be called on every response chunk`, 407 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 408 | ` * @returns {Promise<${returnType}>} Request instance`, 409 | ` */`, 410 | `const ${methodName} = async (data, streamReader, requestOptions) =>`, 411 | `${builder.indentCharacter}protoClient.makeServerStreamRequest("${method.namespace}", data, streamReader, requestOptions);`, 412 | `/**`, 413 | ` * Triggers server stream request to ${method.namespace}, returning the ProtoRequest instance`, 414 | ` * @param {${method.requestType} | StreamReader<${method.responseType}>} [data] Optional data to be sent as part of the request`, 415 | ` * @param {StreamReader<${method.responseType}>} [streamReader] Optional iteration function that will be called on every response chunk`, 416 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 417 | ` * @returns {${returnType}} Request instance`, 418 | ` */`, 419 | `${methodName}.get = (data, streamReader, requestOptions) =>`, 420 | `${builder.indentCharacter}protoClient.getServerStreamRequest("${method.namespace}", data, streamReader, requestOptions);`, 421 | `return ${methodName};` 422 | ); 423 | } 424 | // Server Stream 425 | else if (method.requestStream) { 426 | builder.pushWithIndent( 427 | `/**`, 428 | ` * Client Stream Request to ${method.namespace}`, 429 | ` * @param {StreamWriterSandbox<${method.requestType}> | Readable | EventEmitter} [writerSandbox] Optional async supported callback for writing data to the open stream`, 430 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 431 | ` * @returns {Promise<${returnType}>} Request instance`, 432 | ` */`, 433 | `const ${methodName} = async (writerSandbox, requestOptions) =>`, 434 | `${builder.indentCharacter}protoClient.makeClientStreamRequest("${method.namespace}", writerSandbox, requestOptions);`, 435 | `/**`, 436 | ` * Triggers client stream request to ${method.namespace}, returning the ProtoRequest instance`, 437 | ` * @param {StreamWriterSandbox<${method.requestType}> | Readable | EventEmitter} [writerSandbox] Optional async supported callback for writing data to the open stream`, 438 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 439 | ` * @returns {${returnType}} Request instance`, 440 | ` */`, 441 | `${methodName}.get = (writerSandbox, requestOptions) =>`, 442 | `${builder.indentCharacter}protoClient.getClientStreamRequest("${method.namespace}", writerSandbox, requestOptions);`, 443 | `return ${methodName};` 444 | ); 445 | } 446 | // Unary Request 447 | else { 448 | builder.pushWithIndent( 449 | `/**`, 450 | ` * Unary Request to ${method.namespace}`, 451 | ` * @param {${method.requestType} | null} [data] Optional Data to be sent as part of the request. Defaults to empty object`, 452 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 453 | ` * @returns {Promise<${returnType}>} Request instance`, 454 | ` */`, 455 | `const ${methodName} = async (data, requestOptions) =>`, 456 | `${builder.indentCharacter}protoClient.makeUnaryRequest("${method.namespace}", data, requestOptions);`, 457 | `/**`, 458 | ` * Triggers unary request to ${method.namespace}, returning the ProtoRequest instance`, 459 | ` * @param {${method.requestType} | null} [data] Optional Data to be sent as part of the request. Defaults to empty object`, 460 | ` * @param {AbortController | RequestOptions} [requestOptions] Optional request options for this specific request`, 461 | ` * @returns {${returnType}} Request instance`, 462 | ` */`, 463 | `${methodName}.get = (data, requestOptions) =>`, 464 | `${builder.indentCharacter}protoClient.getUnaryRequest("${method.namespace}", data, requestOptions);`, 465 | `return ${methodName};` 466 | ); 467 | } 468 | 469 | // Close out the method wrapper 470 | builder.push(`})(),`); 471 | } 472 | 473 | // Close out service 474 | builder.deindent(); 475 | builder.push(`},`); 476 | } 477 | // Keep digging 478 | else if (Object.keys(subChain.nested).length) { 479 | builder.push( 480 | `/**`, 481 | ` * ${chainName} Package`, 482 | ` * @namespace ${chainName}`, 483 | ` */`, 484 | `${chainName}: {` 485 | ); 486 | builder.indent(); 487 | this.clientJsMethodChain(subChain.nested, builder); 488 | builder.deindent(); 489 | builder.push(`},`); 490 | } 491 | } 492 | } 493 | 494 | // Writing types for client shortcuts 495 | public async writeClientTypes() { 496 | const builder = new FileBuilder(`${this.outputDirectory}/client.d.ts`); 497 | 498 | builder.push( 499 | `import { ProtoClient, ProtoRequest, RequestOptions, StreamWriterSandbox, StreamReader } from "${this.protoPackagePath}";`, 500 | `import { Readable , EventEmitter } from "stream";`, 501 | `import protos from "./protos";`, 502 | ``, 503 | `/**`, 504 | ` * Configured protoClient used for client shortcuts`, 505 | ` */`, 506 | `export const protoClient: ProtoClient;` 507 | ); 508 | 509 | for (const chainName in this.serviceMethodChain) { 510 | builder.newline(); 511 | builder.push( 512 | `/**`, 513 | ` * ${chainName} Package`, 514 | ` * @namespace ${chainName}`, 515 | ` */`, 516 | `export namespace ${chainName} {` 517 | ); 518 | builder.indent(); 519 | this.clientTypesMethodChain( 520 | this.serviceMethodChain[chainName].nested, 521 | builder 522 | ); 523 | builder.deindent(); 524 | builder.push(`}`); 525 | } 526 | 527 | await builder.write(); 528 | } 529 | 530 | // Builds nested namespaces and service method typings 531 | public clientTypesMethodChain( 532 | serviceChain: ServiceMethodChain, 533 | builder: FileBuilder 534 | ) { 535 | for (const [chainName, subChain] of Object.entries(serviceChain)) { 536 | // Chain entry is a service 537 | if (Object.keys(subChain.methods).length) { 538 | builder.push( 539 | `/**`, 540 | ` * ${chainName} Service`, 541 | ` * @namespace ${chainName}`, 542 | ` */`, 543 | `namespace ${chainName} {` 544 | ); 545 | builder.indent(); 546 | 547 | // Attach all methods 548 | for (const [methodName, method] of Object.entries(subChain.methods)) { 549 | const requestType = `protos.${method.requestType}`; 550 | const responseType = `protos.${method.responseType}`; 551 | const reqResType = `${requestType}, ${responseType}`; 552 | const returnType = `ProtoRequest<${reqResType}>`; 553 | 554 | const args: AddMethodsParams["args"] = { 555 | data: { 556 | type: `${requestType} | null`, 557 | shortType: `${requestType} | null`, 558 | desc: `Data to be sent as part of the request`, 559 | }, 560 | writerSandbox: { 561 | type: `StreamWriterSandbox<${reqResType}>`, 562 | shortType: `StreamWriterSandbox`, 563 | desc: `Async supported callback for writing data to the open stream`, 564 | }, 565 | stream: { 566 | type: `Readable | EventEmitter`, 567 | shortType: `Readable | EventEmitter`, 568 | desc: `Readable like stream for piping into the request stream`, 569 | }, 570 | streamReader: { 571 | type: `StreamReader<${reqResType}>`, 572 | shortType: `StreamReader`, 573 | desc: `Iteration function that will be called on every response chunk`, 574 | }, 575 | abortController: { 576 | type: `AbortController`, 577 | shortType: `AbortController`, 578 | desc: `Data to be sent as part of the request`, 579 | }, 580 | requestOptions: { 581 | type: `RequestOptions`, 582 | shortType: `RequestOptions`, 583 | desc: `Request options for this specific request`, 584 | }, 585 | }; 586 | 587 | builder.push( 588 | `/**`, 589 | ` * Request generation for ${method.namespace}`, 590 | ` */`, 591 | `const ${methodName}: {` 592 | ); 593 | builder.indent(); 594 | 595 | // Bidirectional 596 | if (method.requestStream && method.responseStream) { 597 | builder.addTypedMethods({ 598 | methodType: `Bidirectional Stream`, 599 | namespace: method.namespace, 600 | returnType, 601 | args, 602 | combos: [ 603 | [], 604 | ["writerSandbox"], 605 | ["writerSandbox", "streamReader"], 606 | ["writerSandbox", "streamReader", "abortController"], 607 | ["writerSandbox", "streamReader", "requestOptions"], 608 | ["stream"], 609 | ["stream", "streamReader"], 610 | ["stream", "streamReader", "abortController"], 611 | ["stream", "streamReader", "requestOptions"], 612 | ], 613 | }); 614 | } 615 | // Server Stream 616 | else if (method.responseStream) { 617 | builder.addTypedMethods({ 618 | methodType: `Server Stream`, 619 | namespace: method.namespace, 620 | returnType, 621 | args, 622 | combos: [ 623 | [], 624 | ["streamReader"], 625 | ["data"], 626 | ["data", "streamReader"], 627 | ["data", "streamReader", "abortController"], 628 | ["data", "streamReader", "requestOptions"], 629 | ], 630 | }); 631 | } 632 | // Server Stream 633 | else if (method.requestStream) { 634 | builder.addTypedMethods({ 635 | methodType: `Client Stream`, 636 | namespace: method.namespace, 637 | returnType, 638 | args, 639 | combos: [ 640 | [], 641 | ["writerSandbox"], 642 | ["writerSandbox", "abortController"], 643 | ["writerSandbox", "requestOptions"], 644 | ["stream"], 645 | ["stream", "abortController"], 646 | ["stream", "requestOptions"], 647 | ], 648 | }); 649 | } 650 | // Unary Request 651 | else { 652 | builder.addTypedMethods({ 653 | methodType: `Unary`, 654 | namespace: method.namespace, 655 | returnType, 656 | args, 657 | combos: [ 658 | [], 659 | ["data"], 660 | ["data", "abortController"], 661 | ["data", "requestOptions"], 662 | ], 663 | }); 664 | } 665 | 666 | // Close of method interface 667 | builder.deindent(); 668 | builder.push(`}`); 669 | } 670 | 671 | // Close off service 672 | builder.deindent(); 673 | builder.push(`}`); 674 | } 675 | // Keep digging 676 | else if (Object.keys(subChain.nested).length) { 677 | builder.push( 678 | `/**`, 679 | ` * ${chainName} Package`, 680 | ` * @namespace ${chainName}`, 681 | ` */`, 682 | `namespace ${chainName} {` 683 | ); 684 | builder.indent(); 685 | this.clientTypesMethodChain(subChain.nested, builder); 686 | builder.deindent(); 687 | builder.push(`}`); 688 | } 689 | } 690 | } 691 | } 692 | 693 | /** 694 | * CLI auto runner, parses process.ARGV and runs from there 695 | */ 696 | export async function cli() { 697 | await new ProtoCli(process.argv).run(); 698 | } 699 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { status } from "@grpc/grpc-js"; 2 | 3 | /** 4 | * Supported gRPC request types 5 | * @enum {string} 6 | * @readonly 7 | */ 8 | export enum RequestMethodType { 9 | UnaryRequest = "makeUnaryRequest", 10 | ClientStreamRequest = "makeClientStreamRequest", 11 | ServerStreamRequest = "makeServerStreamRequest", 12 | BidiStreamRequest = "makeBidiStreamRequest", 13 | } 14 | 15 | /** 16 | * Default list of status that can be retried 17 | * @type {status[]} 18 | */ 19 | export const DEFAULT_RETRY_STATUS_CODES: status[] = [ 20 | status.CANCELLED, 21 | status.UNKNOWN, 22 | status.DEADLINE_EXCEEDED, 23 | status.INTERNAL, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ProtoClient"; 2 | export * from "./ProtoRequest"; 3 | export * from "./RequestError"; 4 | export * from "./constants"; 5 | export * from "./interfaces"; 6 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { ProtoRequest } from "./ProtoRequest"; 2 | import type * as Protobuf from "protobufjs"; 3 | import type { UntypedProtoRequest } from "./untyped"; 4 | import type { ProtoClient } from "./ProtoClient"; 5 | import type { 6 | CallOptions, 7 | ChannelCredentials, 8 | ClientOptions, 9 | Metadata, 10 | MetadataValue, 11 | status, 12 | } from "@grpc/grpc-js"; 13 | import type { VerifyOptions } from "@grpc/grpc-js/build/src/channel-credentials"; 14 | import type { RequestMethodType } from "./constants"; 15 | import type { EventEmitter, Readable } from "stream"; 16 | 17 | /** 18 | * Method filter function 19 | * @param method Fully qualified method name to check 20 | * @param request Request instance to check 21 | */ 22 | export type EndpointMatcher = ( 23 | method: string, 24 | request: ProtoRequest 25 | ) => boolean; 26 | 27 | /** 28 | * Middleware to run before each request 29 | * @param request Initialized, but not started, gRPC request 30 | * @param client Parent client instance 31 | */ 32 | export type RequestMiddleware = ( 33 | request: UntypedProtoRequest, 34 | client: ProtoClient 35 | ) => Promise; 36 | 37 | /** 38 | * Service endpoint configuration 39 | */ 40 | export interface ClientEndpoint { 41 | /** 42 | * Remote server address (with port built in) 43 | */ 44 | address: string; 45 | 46 | /** 47 | * For configuring secure credentials when connecting to service endpoint 48 | */ 49 | credentials?: 50 | | ChannelCredentials 51 | | { 52 | /** 53 | * Root certificate data 54 | */ 55 | rootCerts?: Buffer; 56 | 57 | /** 58 | * Client certificate private key, if available 59 | */ 60 | privateKey?: Buffer; 61 | 62 | /** 63 | * Client certificate key chain, if available 64 | */ 65 | certChain?: Buffer; 66 | 67 | /** 68 | * Additional options to modify certificate verification 69 | */ 70 | verifyOptions?: VerifyOptions; 71 | }; 72 | 73 | /** 74 | * gRPC client options for a connection 75 | */ 76 | clientOptions?: ClientOptions; 77 | 78 | /** 79 | * Custom matching of proto method namespace to client endpoint 80 | */ 81 | match?: string | RegExp | EndpointMatcher; 82 | } 83 | 84 | /** 85 | * Client Settings 86 | */ 87 | export interface ClientSettings { 88 | /** 89 | * Either 90 | */ 91 | endpoint: string | ClientEndpoint | ClientEndpoint[]; 92 | 93 | /** 94 | * Indicates if request/response errors should be thrown. Defaults to false 95 | */ 96 | rejectOnError?: boolean; 97 | 98 | /** 99 | * Indicates if error should be thrown when caller cancels the request. Defaults to false 100 | */ 101 | rejectOnAbort?: boolean; 102 | 103 | /** 104 | * Time in milliseconds before cancelling the request. Defaults to 0 for no timeout 105 | */ 106 | timeout?: number; 107 | 108 | /** 109 | * Retry logic for every request 110 | */ 111 | retryOptions?: RequestOptions["retryOptions"]; 112 | } 113 | 114 | /** 115 | * Protobuf parsing settings 116 | */ 117 | export interface ProtoSettings { 118 | /** 119 | * Custom root protobuf namespace, for skipping proto file paths 120 | */ 121 | root?: Protobuf.Root; 122 | 123 | /** 124 | * Proto file path(s) 125 | */ 126 | files?: string | string[]; 127 | 128 | /** 129 | * Parsing options for proto files passed 130 | */ 131 | parseOptions?: Protobuf.IParseOptions; 132 | 133 | /** 134 | * Message conversion options 135 | */ 136 | conversionOptions?: Protobuf.IConversionOptions; 137 | } 138 | 139 | /** 140 | * Iteration callback for streamed responses 141 | * @param row Streamed response row 142 | * @param rowIndex Index for the current chunked row 143 | * @param request Currently active request 144 | */ 145 | export type StreamReader = ( 146 | row: ResponseType, 147 | rowIndex: number, 148 | request: ProtoRequest 149 | ) => Promise; 150 | 151 | /** 152 | * Writing wrapper for streamed requests 153 | * @param write Async function for sending an object over the write stream 154 | * @param request Currently active request 155 | */ 156 | export type StreamWriterSandbox = ( 157 | write: StreamWriter, 158 | request: ProtoRequest 159 | ) => Promise; 160 | 161 | /** 162 | * Writing function for sending objects to the write stream 163 | * @param data Request data row to be streamed 164 | * @param encoding Write encoding for the data 165 | */ 166 | export type StreamWriter = ( 167 | data: RequestType, 168 | encoding?: string 169 | ) => Promise; 170 | 171 | /** 172 | * Custom retry logic options 173 | */ 174 | export interface RequestRetryOptions { 175 | /** 176 | * Number of times to retry request. Defaults to none 177 | */ 178 | retryCount?: number; 179 | 180 | /** 181 | * Status codes request is allowed to retry on 182 | */ 183 | status?: status | status[]; 184 | } 185 | 186 | /** 187 | * Request specific options 188 | */ 189 | export interface RequestOptions { 190 | /** 191 | * Controller for aborting the active request 192 | */ 193 | abortController?: AbortController; 194 | 195 | /** 196 | * Metadata to be attached to the request 197 | */ 198 | metadata?: Record | Metadata; 199 | 200 | /** 201 | * Request specific options 202 | */ 203 | callOptions?: CallOptions; 204 | 205 | /** 206 | * Time in milliseconds before cancelling the request. Defaults to 0 for no timeout 207 | */ 208 | timeout?: number; 209 | 210 | /** 211 | * Indicates retry logic that should be applied to the request 212 | * @alias boolean Retries the request once for default status code failures when true, disable retry when false 213 | * @alias number Number of retries to allow for default status code failures 214 | * @alias RequestRetryOptions Custom retry options 215 | */ 216 | retryOptions?: boolean | number | RequestRetryOptions; 217 | } 218 | 219 | /** 220 | * Parameters for generating any proto request 221 | */ 222 | export interface GenericRequestParams { 223 | /** 224 | * Fully qualified path of the method for the request that can be used by protobufjs.lookup 225 | */ 226 | method: string; 227 | /** 228 | * Type of method for the request 229 | */ 230 | requestMethodType?: RequestMethodType; 231 | /** 232 | * Data to be sent for unary requests 233 | */ 234 | data?: RequestType; 235 | /** 236 | * Pipes data from a stream to the request stream 237 | */ 238 | pipeStream?: EventEmitter | Readable; 239 | /** 240 | * Write sandbox for sending data on request streams 241 | */ 242 | writerSandbox?: StreamWriterSandbox; 243 | /** 244 | * Read iterator for listening on response streams 245 | */ 246 | streamReader?: StreamReader; 247 | /** 248 | * Request specific options 249 | */ 250 | requestOptions?: RequestOptions; 251 | } 252 | 253 | /** 254 | * Unix timestamps of important markers throughout the request lifecycle 255 | */ 256 | export interface RequestLifecycleTiming { 257 | /** Timestamp of when the request started */ 258 | started_at: number; 259 | /** Timestamp of when the request resolved with an error */ 260 | errored_at?: number; 261 | /** Timestamp of when the request resolved */ 262 | ended_at?: number; 263 | 264 | /** Middleware specific time logs */ 265 | middleware: { 266 | /** Timestamp of when request starts to run all middleware */ 267 | started_at?: number; 268 | /** Timestamp of when error occurs during middleware */ 269 | errored_at?: number; 270 | /** Timestamp of when all middleware is complete */ 271 | ended_at?: number; 272 | 273 | /** Individual timestamps for each middleware that is run */ 274 | middleware: Array<{ 275 | /** Timestamp of when individual middleware starts */ 276 | started_at: number; 277 | /** Timestamp of when individual middleware ends */ 278 | ended_at?: number; 279 | }>; 280 | }; 281 | 282 | /** Time markers for each attempt (or retry) this request makes */ 283 | attempts: Array<{ 284 | /** Timestamp of when this attempt starts */ 285 | started_at: number; 286 | /** Timestamp of when this attempt receives response metadata */ 287 | metadata_received_at?: number; 288 | /** Timestamp of when this attempt receives the status data (includes trailing metadata) */ 289 | status_received_at?: number; 290 | /** Timestamp of when this attempt finishes with an error */ 291 | errored_at?: number; 292 | /** Timestamp of when this attempt finishes */ 293 | ended_at?: number; 294 | 295 | /** Time markers for writers sandbox on this attempt */ 296 | write_stream?: { 297 | /** Timestamp of when the writers sandbox starts */ 298 | started_at: number; 299 | /** Timestamp of when an error is thrown from the sandbox */ 300 | errored_at?: number; 301 | /** Timestamp of when the sandbox is complete */ 302 | ended_at?: number; 303 | 304 | /** Time markers for individual writes */ 305 | messages: Array<{ 306 | /** Timestamp of when a write to the stream starts */ 307 | started_at: number; 308 | /** Timestamp of when a write to the stream completes */ 309 | written_at?: number; 310 | }>; 311 | }; 312 | 313 | /** Time markers when piping a stream to the request stream for this attempt */ 314 | pipe_stream?: { 315 | /** Timestamp of when piping starts */ 316 | started_at: number; 317 | /** Timestamp of when an error is found during piping */ 318 | errored_at?: number; 319 | /** Timestamp of when piping completes */ 320 | ended_at?: number; 321 | 322 | /** Time markers for individual piped messages */ 323 | messages: Array<{ 324 | /** Timestamp of when a message is received from the pipe */ 325 | received_at: number; 326 | /** Timestamp of when a message is finishes writing to the request stream */ 327 | written_at?: number; 328 | }>; 329 | }; 330 | 331 | /** Time markers when reading messages from the response stream for this attempt */ 332 | read_stream?: { 333 | /** Timestamp of when the reading starts */ 334 | started_at: number; 335 | /** Timestamp of when an error is found during reading */ 336 | errored_at?: number; 337 | /** Timestamp of when reading completes */ 338 | ended_at?: number; 339 | /** Timestamp of when the last [used] message is fully processed */ 340 | last_processed_at?: number; 341 | 342 | /** Time markers for individual read messages */ 343 | messages: Array<{ 344 | /** Timestamp of when a message is received from the response */ 345 | received_at: number; 346 | /** Timestamp of when a message iteterator completes processing of the message */ 347 | ended_at?: number; 348 | }>; 349 | }; 350 | }>; 351 | } 352 | -------------------------------------------------------------------------------- /src/untyped.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { ProtoRequest } from "./ProtoRequest"; 3 | 4 | // This file provides type backdoors for specific use cases 5 | 6 | export type UntypedProtoRequest = ProtoRequest; 7 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | EndpointMatcher, 3 | RequestOptions, 4 | RequestRetryOptions, 5 | } from "./interfaces"; 6 | 7 | /** 8 | * Generates a function for matching namespaces to requests 9 | * @param {string | RegExp | EndpointMatcher} match Multi-type endpoint matcher to convert 10 | * @returns Matcher function 11 | * @private 12 | * @package 13 | */ 14 | export const generateEndpointMatcher = ( 15 | match?: string | RegExp | EndpointMatcher 16 | ): EndpointMatcher => { 17 | if (!match) { 18 | return () => true; 19 | } else if (typeof match === "string") { 20 | if (/\.\*$/.test(match)) { 21 | const matchNamespace = match.replace(/\*$/, ""); 22 | return (method) => method.startsWith(matchNamespace); 23 | } else { 24 | return (method) => method === match; 25 | } 26 | } else if (match instanceof RegExp) { 27 | return (method) => match.test(method); 28 | } else { 29 | return match; 30 | } 31 | }; 32 | 33 | /** 34 | * Normalizes provided retry options into configuration object 35 | * @param {boolean | number | RequestRetryOptions} options Various retry option types 36 | * @returns {RequestRetryOptions | undefined} Normalized retry options if they are passed in 37 | * @private 38 | * @package 39 | */ 40 | export const normalizeRetryOptions = ( 41 | options: RequestOptions["retryOptions"] 42 | ): RequestRetryOptions | void => { 43 | if (options !== undefined) { 44 | if (options === true) { 45 | return { retryCount: 1 }; 46 | } else if (options === false) { 47 | return { retryCount: 0 }; 48 | } else if (typeof options === "number") { 49 | return { retryCount: options }; 50 | } else { 51 | return options; 52 | } 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /test/Cli.test.ts: -------------------------------------------------------------------------------- 1 | import { promises } from "fs"; 2 | import { ProtoCli } from "../src/cli/cli"; 3 | import { PROTO_FILE_PATHS } from "./utils"; 4 | 5 | const OUTPUT_DIR = `${__dirname}/client/`; 6 | 7 | describe("Cli", () => { 8 | beforeAll(async () => { 9 | try { 10 | const stat = await promises.stat(OUTPUT_DIR); 11 | if (stat.isDirectory()) { 12 | await promises.rm(OUTPUT_DIR, { recursive: true, force: true }); 13 | } 14 | await promises.mkdir(OUTPUT_DIR); 15 | } catch { 16 | await promises.mkdir(OUTPUT_DIR); 17 | } 18 | 19 | const cli = new ProtoCli([ 20 | `/bin/node`, 21 | `${__dirname}/../bin/proto-client`, 22 | `-o`, 23 | OUTPUT_DIR, 24 | `--keep-case`, 25 | ...PROTO_FILE_PATHS, 26 | ]); 27 | cli.protoPackagePath = `../../src`; 28 | await cli.run(); 29 | }); 30 | 31 | test("should verify all expected files have been generated", async () => { 32 | expect( 33 | (await promises.stat(`${OUTPUT_DIR}raw-protos.json`)).isFile() 34 | ).toStrictEqual(true); 35 | expect( 36 | (await promises.stat(`${OUTPUT_DIR}protos.js`)).isFile() 37 | ).toStrictEqual(true); 38 | expect( 39 | (await promises.stat(`${OUTPUT_DIR}protos.d.ts`)).isFile() 40 | ).toStrictEqual(true); 41 | expect( 42 | (await promises.stat(`${OUTPUT_DIR}client.js`)).isFile() 43 | ).toStrictEqual(true); 44 | expect( 45 | (await promises.stat(`${OUTPUT_DIR}client.d.ts`)).isFile() 46 | ).toStrictEqual(true); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/MockServiceError.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError, status, Metadata } from "@grpc/grpc-js"; 2 | 3 | export class MockServiceError extends Error implements ServiceError { 4 | public code: status; 5 | public details: string; 6 | public metadata: Metadata; 7 | 8 | constructor(code: status, details?: string) { 9 | details ||= `${code} ${status[code]}`; 10 | super(details); 11 | this.code = code; 12 | this.details = details; 13 | this.metadata = new Metadata(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/ProtoClient.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ServerUnaryCall, 3 | sendUnaryData, 4 | Client, 5 | ServerReadableStream, 6 | ServerWritableStream, 7 | UntypedServiceImplementation, 8 | } from "@grpc/grpc-js"; 9 | import * as Protobuf from "protobufjs"; 10 | import { 11 | ProtoClient, 12 | ProtoRequest, 13 | ProtoSettings, 14 | RequestMethodType, 15 | } from "../src"; 16 | import { 17 | startServer, 18 | GetCustomerRequest, 19 | Customer, 20 | PROTO_FILE_PATHS, 21 | makeUnaryRequest, 22 | CustomersResponse, 23 | FindCustomersRequest, 24 | makeClientStreamRequest, 25 | makeServerStreamRequest, 26 | wait, 27 | generateProtoServer, 28 | } from "./utils"; 29 | 30 | jest.setTimeout(1000); 31 | 32 | describe("ProtoClient", () => { 33 | let client: ProtoClient; 34 | let request: ProtoRequest; 35 | let serviceMethods: UntypedServiceImplementation; 36 | let RESPONSE_DELAY = 0; 37 | 38 | beforeEach(async () => { 39 | serviceMethods = { 40 | // Unary 41 | GetCustomer: ( 42 | _call: ServerUnaryCall, 43 | callback: sendUnaryData 44 | ) => { 45 | setTimeout(() => { 46 | callback(null, { id: "github", name: "Github" }); 47 | }, RESPONSE_DELAY); 48 | }, 49 | 50 | // Client Stream 51 | EditCustomer: ( 52 | call: ServerReadableStream, 53 | callback: sendUnaryData 54 | ) => { 55 | const customers: Customer[] = []; 56 | 57 | call.on("data", (row: Customer) => { 58 | customers.push(row); 59 | }); 60 | 61 | call.on("end", () => { 62 | if (call.cancelled) { 63 | return; 64 | } 65 | 66 | setTimeout(() => { 67 | if (!call.cancelled) { 68 | callback(null, { customers }); 69 | } 70 | }, RESPONSE_DELAY); 71 | }); 72 | }, 73 | 74 | // Server Stream 75 | FindCustomers: ( 76 | call: ServerWritableStream 77 | ) => { 78 | setTimeout(() => { 79 | call.write({ id: "github", name: "Github" }, () => { 80 | if (call.writable) { 81 | call.write({ id: "npm", name: "NPM" }, () => { 82 | if (call.writable) { 83 | call.end(); 84 | } 85 | }); 86 | } 87 | }); 88 | }, RESPONSE_DELAY); 89 | }, 90 | }; 91 | 92 | const results = await startServer(serviceMethods); 93 | client = results.client; 94 | 95 | request = new ProtoRequest( 96 | { 97 | method: "customers.Customers.GetCustomer", 98 | requestMethodType: RequestMethodType.UnaryRequest, 99 | }, 100 | client 101 | ); 102 | }); 103 | 104 | test("should proxy settings passed on init to each configuration method", () => { 105 | const client = new ProtoClient({ 106 | clientSettings: { 107 | endpoint: `0.0.0.0:8081`, 108 | }, 109 | protoSettings: { 110 | files: PROTO_FILE_PATHS, 111 | }, 112 | conversionOptions: { 113 | defaults: true, 114 | }, 115 | }); 116 | 117 | expect(client.clientSettings).toEqual({ 118 | endpoint: `0.0.0.0:8081`, 119 | }); 120 | expect(client.protoSettings).toEqual({ 121 | files: PROTO_FILE_PATHS, 122 | }); 123 | expect(client.protoConversionOptions).toEqual({ 124 | defaults: true, 125 | }); 126 | }); 127 | 128 | describe("configureClient", () => { 129 | test("should close out existing connections before setting up a new one", () => { 130 | const closeSpy = jest.spyOn(client, "close").mockReturnValue(undefined); 131 | 132 | client.configureClient({ 133 | endpoint: `0.0.0.0:9091`, 134 | }); 135 | expect(closeSpy).toHaveBeenCalledTimes(1); 136 | }); 137 | }); 138 | 139 | describe("configureProtos", () => { 140 | test("passing proto options should override defaults", () => { 141 | const loadSyncSpy = jest.spyOn(Protobuf.Root.prototype, "loadSync"); 142 | 143 | const settings: ProtoSettings = { 144 | files: PROTO_FILE_PATHS, 145 | parseOptions: { 146 | keepCase: false, 147 | alternateCommentMode: false, 148 | }, 149 | conversionOptions: { 150 | defaults: true, 151 | }, 152 | }; 153 | 154 | client.configureProtos(settings); 155 | expect(client.protoSettings).toStrictEqual(settings); 156 | expect(loadSyncSpy).toHaveBeenCalledTimes(1); 157 | expect(loadSyncSpy).toHaveBeenCalledWith(PROTO_FILE_PATHS, { 158 | keepCase: false, 159 | alternateCommentMode: false, 160 | }); 161 | }); 162 | 163 | test("passing proto instance should assign to overall client", () => { 164 | const root = new Protobuf.Root(); 165 | 166 | client.configureProtos({ root }); 167 | expect(client.getRoot()).toStrictEqual(root); 168 | }); 169 | 170 | test("should throw when no root or proto files defined", () => { 171 | expect(() => client.configureProtos({})).toThrow( 172 | "Must define either a root protobuf object, or path to proto files" 173 | ); 174 | }); 175 | }); 176 | 177 | describe("configureConversionOptions", () => { 178 | test("should assign conversion options for use in deserialization", () => { 179 | expect(client.protoConversionOptions).toEqual({ 180 | longs: Number, 181 | enums: String, 182 | defaults: false, 183 | oneofs: true, 184 | }); 185 | 186 | client.configureConversionOptions({ defaults: true }); 187 | expect(client.protoConversionOptions).toEqual({ defaults: true }); 188 | }); 189 | }); 190 | 191 | describe("getClient", () => { 192 | test("should return the existing client, or throw an error", () => { 193 | expect(client.getClient(request)).toBeInstanceOf(Client); 194 | 195 | client = new ProtoClient(); 196 | expect(() => client.getClient(request)).toThrow( 197 | `ProtoClient is not yet configured` 198 | ); 199 | 200 | client.configureClient({ 201 | endpoint: { 202 | address: `0.0.0.0:9091`, 203 | match: "foo.bar.baz", 204 | }, 205 | }); 206 | expect(() => client.getClient(request)).toThrow( 207 | `Service method '${request.method}' has no configured endpoint` 208 | ); 209 | }); 210 | }); 211 | 212 | describe("getRoot", () => { 213 | test("should return the proto root object, or throw an error", () => { 214 | expect(client.getRoot()).toBeInstanceOf(Protobuf.Root); 215 | 216 | client = new ProtoClient(); 217 | expect(() => client.getRoot()).toThrow( 218 | `ProtoClient protos are not yet configured` 219 | ); 220 | }); 221 | }); 222 | 223 | describe("abortRequests", () => { 224 | let abort1: AbortController; 225 | let abort2: AbortController; 226 | let abort3: AbortController; 227 | 228 | beforeEach(async () => { 229 | RESPONSE_DELAY = 50; 230 | abort1 = new AbortController(); 231 | abort2 = new AbortController(); 232 | abort3 = new AbortController(); 233 | 234 | makeUnaryRequest({}, abort1); 235 | makeServerStreamRequest({}, async () => undefined, abort2); 236 | makeClientStreamRequest(async () => undefined, abort3); 237 | 238 | await wait(10); 239 | }); 240 | afterEach(() => client.close()); 241 | 242 | test("string matching", () => { 243 | client.abortRequests("customers.Customers.GetCustomer"); 244 | expect(abort1.signal.aborted).toStrictEqual(true); 245 | expect(abort2.signal.aborted).toStrictEqual(false); 246 | expect(abort3.signal.aborted).toStrictEqual(false); 247 | }); 248 | 249 | test("regex matching", () => { 250 | client.abortRequests(/\.EditCustomer$/); 251 | expect(abort1.signal.aborted).toStrictEqual(false); 252 | expect(abort2.signal.aborted).toStrictEqual(false); 253 | expect(abort3.signal.aborted).toStrictEqual(true); 254 | }); 255 | 256 | test("function matching", () => { 257 | client.abortRequests( 258 | (method) => method === "customers.Customers.FindCustomers" 259 | ); 260 | expect(abort1.signal.aborted).toStrictEqual(false); 261 | expect(abort2.signal.aborted).toStrictEqual(true); 262 | expect(abort3.signal.aborted).toStrictEqual(false); 263 | }); 264 | }); 265 | 266 | describe("close", () => { 267 | test("should abort all requests before closing the client", () => { 268 | const rpcClient = client.getClient(request); 269 | jest.spyOn(rpcClient, "close"); 270 | jest.spyOn(client, "abortRequests"); 271 | 272 | client.close(); 273 | expect(client.abortRequests).toHaveBeenCalledTimes(1); 274 | expect(rpcClient.close).toHaveBeenCalledTimes(1); 275 | 276 | // rpcClient should be removed after closed, requiring a reconfigure 277 | expect(() => client.getClient(request)).toThrow( 278 | `ProtoClient is not yet configured` 279 | ); 280 | }); 281 | }); 282 | 283 | describe("multiple endpoints", () => { 284 | test("should be able to connect to multiple servers with the same client", async () => { 285 | const server1 = await generateProtoServer({ 286 | GetCustomer: serviceMethods.GetCustomer, 287 | }); 288 | const server2 = await generateProtoServer({ 289 | EditCustomer: serviceMethods.EditCustomer, 290 | }); 291 | const server3 = await generateProtoServer({ 292 | FindCustomers: serviceMethods.FindCustomers, 293 | }); 294 | 295 | client = new ProtoClient({ 296 | protoSettings: { 297 | files: PROTO_FILE_PATHS, 298 | }, 299 | clientSettings: { 300 | endpoint: [ 301 | { 302 | address: `0.0.0.0:${server1.port}`, 303 | match: `customers.Customers.GetCustomer`, 304 | }, 305 | { 306 | address: `0.0.0.0:${server2.port}`, 307 | match: `customers.Customers.EditCustomer`, 308 | }, 309 | { 310 | address: `0.0.0.0:${server3.port}`, 311 | match: `customers.Customers.FindCustomers`, 312 | }, 313 | ], 314 | }, 315 | }); 316 | 317 | expect( 318 | await client.makeUnaryRequest("customers.Customers.GetCustomer") 319 | ).toBeInstanceOf(ProtoRequest); 320 | expect( 321 | await client.makeClientStreamRequest( 322 | "customers.Customers.EditCustomer", 323 | async (write) => { 324 | await write({ id: "github", name: "Github" }); 325 | } 326 | ) 327 | ).toBeInstanceOf(ProtoRequest); 328 | expect( 329 | await client.makeServerStreamRequest( 330 | "customers.Customers.FindCustomers", 331 | async () => undefined 332 | ) 333 | ).toBeInstanceOf(ProtoRequest); 334 | }); 335 | }); 336 | }); 337 | -------------------------------------------------------------------------------- /test/ProtoRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ServerUnaryCall, 3 | sendUnaryData, 4 | status, 5 | ServerDuplexStream, 6 | } from "@grpc/grpc-js"; 7 | import { 8 | startServer, 9 | GetCustomerRequest, 10 | Customer, 11 | getClient, 12 | getUnaryRequest, 13 | wait, 14 | makeUnaryRequest, 15 | makeBidiStreamRequest, 16 | } from "./utils"; 17 | import { MockServiceError } from "./MockServiceError"; 18 | import { ProtoClient, ProtoRequest } from "../src"; 19 | import { Readable } from "stream"; 20 | 21 | describe("ProtoRequest", () => { 22 | let client: ProtoClient; 23 | let RESPONSE_DELAY: number; 24 | let THROW_ERROR: MockServiceError | undefined; 25 | let methodTimerId: NodeJS.Timeout | undefined; 26 | 27 | beforeEach(async () => { 28 | RESPONSE_DELAY = 0; 29 | THROW_ERROR = undefined; 30 | methodTimerId = undefined; 31 | 32 | await startServer({ 33 | GetCustomer: ( 34 | _call: ServerUnaryCall, 35 | callback: sendUnaryData 36 | ) => { 37 | methodTimerId = setTimeout(() => { 38 | methodTimerId = undefined; 39 | if (THROW_ERROR) { 40 | callback(THROW_ERROR); 41 | } else { 42 | callback(null, { id: "github", name: "Github" }); 43 | } 44 | }, RESPONSE_DELAY); 45 | }, 46 | CreateCustomers: (call: ServerDuplexStream) => { 47 | call.on("data", (customer) => { 48 | call.write(customer); 49 | }); 50 | 51 | call.on("end", () => { 52 | call.end(); 53 | }); 54 | }, 55 | }); 56 | 57 | client = getClient(); 58 | }); 59 | 60 | afterEach(() => { 61 | if (methodTimerId) { 62 | clearTimeout(methodTimerId); 63 | } 64 | }); 65 | 66 | test("should queue up multiple run requests", async () => { 67 | const request = getUnaryRequest(); 68 | 69 | const results = await Promise.all([ 70 | request.waitForEnd(), 71 | request.waitForEnd(), 72 | request.waitForEnd(), 73 | ]); 74 | 75 | expect(results.map((req) => req.result)).toEqual([ 76 | { id: "github", name: "Github" }, 77 | { id: "github", name: "Github" }, 78 | { id: "github", name: "Github" }, 79 | ]); 80 | 81 | // Triggering run again should return the current request 82 | expect(await request.waitForEnd()).toStrictEqual(request); 83 | }); 84 | 85 | test("should rethrow errors when running the same request", async () => { 86 | THROW_ERROR = new MockServiceError( 87 | status.INTERNAL, 88 | "Generic Service Error" 89 | ); 90 | 91 | client.clientSettings.rejectOnError = true; 92 | const request = getUnaryRequest(); 93 | 94 | // Wait for initial response to return 95 | await expect(request.waitForEnd()).rejects.toThrow( 96 | `${status.INTERNAL} INTERNAL: Generic Service Error` 97 | ); 98 | 99 | // Running the same request should throw again 100 | await expect(request.waitForEnd()).rejects.toThrow( 101 | `${status.INTERNAL} INTERNAL: Generic Service Error` 102 | ); 103 | }); 104 | 105 | test("should throw an error when attempting to request from a service that does not exist", async () => { 106 | await expect( 107 | client.makeUnaryRequest("foo.bar.not.here", {}) 108 | ).rejects.toThrow(`no such Service 'foo.bar.not' in Root`); 109 | }); 110 | 111 | test("should throw an error when attempting to request from a method that does not exist on the service", async () => { 112 | await expect( 113 | client.makeUnaryRequest("customers.Customers.NotHere", {}) 114 | ).rejects.toThrow(`Method NotHere not found on customers.Customers`); 115 | }); 116 | 117 | test("should throw an error if attempting to request a method with an incorrect type", async () => { 118 | await expect( 119 | client.makeServerStreamRequest( 120 | "customers.Customers.GetCustomer", 121 | async () => undefined 122 | ) 123 | ).rejects.toThrow( 124 | `makeServerStreamRequest does not support method 'customers.Customers.GetCustomer', use makeUnaryRequest instead` 125 | ); 126 | }); 127 | 128 | test("should throw an error if attempting to manually start a requset multiple times", async () => { 129 | const request = getUnaryRequest(); 130 | 131 | expect(() => request.start()).toThrow( 132 | `ProtoRequest.start is an internal method that should not be called` 133 | ); 134 | }); 135 | 136 | describe("timing", () => { 137 | test("should track basic timing entries for each request", async () => { 138 | client.useMiddleware(async () => undefined); 139 | const request = await makeBidiStreamRequest( 140 | async (write) => { 141 | await write({ id: "github", name: "Github" }); 142 | await write({ id: "npm", name: "NPM" }); 143 | }, 144 | async () => { 145 | await wait(5); 146 | } 147 | ); 148 | 149 | expect(request.timing).toEqual({ 150 | started_at: expect.any(Number), 151 | ended_at: expect.any(Number), 152 | 153 | middleware: { 154 | started_at: expect.any(Number), 155 | ended_at: expect.any(Number), 156 | 157 | middleware: [ 158 | { 159 | started_at: expect.any(Number), 160 | ended_at: expect.any(Number), 161 | }, 162 | ], 163 | }, 164 | 165 | attempts: [ 166 | { 167 | started_at: expect.any(Number), 168 | status_received_at: expect.any(Number), 169 | metadata_received_at: expect.any(Number), 170 | ended_at: expect.any(Number), 171 | 172 | write_stream: { 173 | started_at: expect.any(Number), 174 | ended_at: expect.any(Number), 175 | 176 | messages: [ 177 | { 178 | started_at: expect.any(Number), 179 | written_at: expect.any(Number), 180 | }, 181 | { 182 | started_at: expect.any(Number), 183 | written_at: expect.any(Number), 184 | }, 185 | ], 186 | }, 187 | 188 | read_stream: { 189 | started_at: expect.any(Number), 190 | ended_at: expect.any(Number), 191 | last_processed_at: expect.any(Number), 192 | 193 | messages: [ 194 | { 195 | received_at: expect.any(Number), 196 | ended_at: expect.any(Number), 197 | }, 198 | { 199 | received_at: expect.any(Number), 200 | ended_at: expect.any(Number), 201 | }, 202 | ], 203 | }, 204 | }, 205 | ], 206 | }); 207 | }); 208 | 209 | test("should also track piped streams as well", async () => { 210 | const request = await makeBidiStreamRequest( 211 | Readable.from([ 212 | { id: "github", name: "Github" }, 213 | { id: "npm", name: "NPM" }, 214 | ]), 215 | async () => { 216 | await wait(5); 217 | } 218 | ); 219 | 220 | expect(request.timing.attempts[0].pipe_stream).toEqual({ 221 | started_at: expect.any(Number), 222 | ended_at: expect.any(Number), 223 | 224 | messages: [ 225 | { 226 | received_at: expect.any(Number), 227 | written_at: expect.any(Number), 228 | }, 229 | { 230 | received_at: expect.any(Number), 231 | written_at: expect.any(Number), 232 | }, 233 | ], 234 | }); 235 | }); 236 | }); 237 | 238 | describe("Queued Errors", () => { 239 | let request: ProtoRequest; 240 | 241 | beforeEach(async () => { 242 | THROW_ERROR = new MockServiceError( 243 | status.INTERNAL, 244 | "Mock Internal Error" 245 | ); 246 | RESPONSE_DELAY = 100; 247 | request = getUnaryRequest(); 248 | await wait(25); 249 | }); 250 | 251 | test("should resolve without throwing by default", async () => { 252 | await request.waitForEnd(); 253 | expect(request.error?.message).toStrictEqual( 254 | `13 INTERNAL: Mock Internal Error` 255 | ); 256 | }); 257 | 258 | test("should resolve without throwing when rejectOnError is disabled", async () => { 259 | client.clientSettings.rejectOnError = false; 260 | await request.waitForEnd(); 261 | expect(request.error?.message).toStrictEqual( 262 | `13 INTERNAL: Mock Internal Error` 263 | ); 264 | }); 265 | 266 | test("should throw error when rejectOnError is enabled", async () => { 267 | client.clientSettings.rejectOnError = true; 268 | await expect(request.waitForEnd()).rejects.toThrow( 269 | `13 INTERNAL: Mock Internal Error` 270 | ); 271 | }); 272 | 273 | test("should not resolve when rejectOnAbort is not enabled", async () => { 274 | return new Promise((resolve, reject) => { 275 | request 276 | .waitForEnd() 277 | .then(() => 278 | reject("should not resolve when rejectOnAbort is disabled") 279 | ) 280 | .catch(() => 281 | reject("should not resolve when rejectOnAbort is disabled") 282 | ); 283 | 284 | request.on("aborted", () => { 285 | setTimeout(() => resolve(), 50); 286 | }); 287 | request.abortController.abort(); 288 | }); 289 | }); 290 | 291 | test("should throw error when rejectOnAbort is enabled", async () => { 292 | client.clientSettings.rejectOnAbort = true; 293 | 294 | return new Promise((resolve, reject) => { 295 | request 296 | .waitForEnd() 297 | .then(() => 298 | reject("should not resolve when rejectOnAbort is enabled") 299 | ) 300 | .catch((e) => { 301 | try { 302 | expect(e).toBeInstanceOf(Error); 303 | expect((e as Error).message).toStrictEqual( 304 | `Cancelled makeUnaryRequest for 'customers.Customers.GetCustomer'` 305 | ); 306 | resolve(); 307 | } catch (testError) { 308 | reject(testError); 309 | } 310 | }); 311 | 312 | request.on("aborted", () => { 313 | setTimeout( 314 | () => 315 | reject( 316 | `should have already resolved by the time aborted event is called` 317 | ), 318 | 50 319 | ); 320 | }); 321 | request.abortController.abort(); 322 | }); 323 | }); 324 | }); 325 | 326 | describe("Resolved Errors", () => { 327 | let request: ProtoRequest; 328 | 329 | beforeEach(async () => { 330 | THROW_ERROR = new MockServiceError( 331 | status.INTERNAL, 332 | "Mock Internal Error" 333 | ); 334 | request = await makeUnaryRequest(); 335 | }); 336 | 337 | test("should return immediately without throwing by default", async () => { 338 | await request.waitForEnd(); 339 | expect(request.error?.message).toStrictEqual( 340 | `13 INTERNAL: Mock Internal Error` 341 | ); 342 | }); 343 | 344 | test("should return immediately without throwing when rejectOnError is disabled", async () => { 345 | client.clientSettings.rejectOnError = false; 346 | await request.waitForEnd(); 347 | expect(request.error?.message).toStrictEqual( 348 | `13 INTERNAL: Mock Internal Error` 349 | ); 350 | }); 351 | 352 | test("should throw error immediately when rejectOnError is enabled", async () => { 353 | client.clientSettings.rejectOnError = true; 354 | await expect(request.waitForEnd()).rejects.toThrow( 355 | `13 INTERNAL: Mock Internal Error` 356 | ); 357 | }); 358 | 359 | test("should not return when rejectOnAbort is not enabled", async () => { 360 | RESPONSE_DELAY = 100; 361 | request = getUnaryRequest(); 362 | await wait(50); 363 | request.abortController.abort(); 364 | return new Promise((resolve, reject) => { 365 | request 366 | .waitForEnd() 367 | .then(() => 368 | reject("should not resolve when rejectOnAbort is disabled") 369 | ) 370 | .catch(() => 371 | reject("should not resolve when rejectOnAbort is disabled") 372 | ); 373 | 374 | setTimeout(() => { 375 | if (request.isActive) { 376 | reject("request is still active, can not confirm abort worked"); 377 | } else { 378 | resolve(); 379 | } 380 | }, 75); 381 | }); 382 | }); 383 | 384 | test("should throw error immediately when rejectOnAbort is enabled", async () => { 385 | client.clientSettings.rejectOnAbort = true; 386 | RESPONSE_DELAY = 100; 387 | request = getUnaryRequest(); 388 | await wait(50); 389 | request.abortController.abort(); 390 | await wait(25); 391 | 392 | expect(request.isActive).toStrictEqual(false); 393 | await expect(request.waitForEnd()).rejects.toThrow( 394 | `Cancelled makeUnaryRequest for 'customers.Customers.GetCustomer'` 395 | ); 396 | }); 397 | }); 398 | 399 | describe("toString", () => { 400 | test("should mark timings of writes & reads", async () => { 401 | const request = await makeBidiStreamRequest( 402 | async (write) => { 403 | await write({ id: "github", name: "Github" }); 404 | await write({ id: "npm", name: "NPM" }); 405 | }, 406 | async () => { 407 | await wait(5); 408 | } 409 | ); 410 | 411 | expect(`${request}`).toMatch( 412 | /^\[BidiStreamRequest:OK\] "customers.Customers.CreateCustomers" \((\d+)ms\) attempts:1 writes:(\d+)ms reads:(\d+)ms$/ 413 | ); 414 | }); 415 | 416 | test("should mark pipe timings", async () => { 417 | const request = await makeBidiStreamRequest( 418 | Readable.from([ 419 | { id: "github", name: "Github" }, 420 | { id: "npm", name: "NPM" }, 421 | ]), 422 | async () => { 423 | await wait(5); 424 | } 425 | ); 426 | 427 | expect(`${request}`).toMatch( 428 | /^\[BidiStreamRequest:OK\] "customers.Customers.CreateCustomers" \((\d+)ms\) attempts:1 pipe:(\d+)ms reads:(\d+)ms$/ 429 | ); 430 | }); 431 | 432 | test("should only mark timings that are hit", async () => { 433 | const request = await makeUnaryRequest(); 434 | 435 | expect(`${request}`).toMatch( 436 | /^\[UnaryRequest:OK\] "customers.Customers.GetCustomer" \((\d+)ms\) attempts:1$/ 437 | ); 438 | }); 439 | }); 440 | }); 441 | -------------------------------------------------------------------------------- /test/RequestError.test.ts: -------------------------------------------------------------------------------- 1 | import { status } from "@grpc/grpc-js"; 2 | import { ProtoClient, ProtoRequest, RequestMethodType } from "../src"; 3 | import { RequestError } from "../src/RequestError"; 4 | import { Customer, GetCustomerRequest, PROTO_FILE_PATHS } from "./utils"; 5 | 6 | describe("RequestError", () => { 7 | let client: ProtoClient; 8 | let request: ProtoRequest; 9 | 10 | beforeEach(() => { 11 | client = new ProtoClient({ 12 | clientSettings: { 13 | endpoint: { 14 | address: `0.0.0.0:9001`, 15 | }, 16 | rejectOnAbort: true, 17 | }, 18 | protoSettings: { 19 | files: PROTO_FILE_PATHS, 20 | }, 21 | }); 22 | 23 | request = new ProtoRequest( 24 | { 25 | method: "customers.Customers.GetCustomer", 26 | requestMethodType: RequestMethodType.UnaryRequest, 27 | }, 28 | client 29 | ); 30 | }); 31 | 32 | test("should assign parameters and auto set code to aborted", () => { 33 | const error = new RequestError(status.CANCELLED, request); 34 | expect(error.message).toStrictEqual( 35 | `Cancelled makeUnaryRequest for 'customers.Customers.GetCustomer'` 36 | ); 37 | expect(error.code).toStrictEqual(status.CANCELLED); 38 | expect(error.metadata).toStrictEqual(request.metadata); 39 | }); 40 | 41 | test("should assign default details to non-special codes", () => { 42 | const error = new RequestError(status.INTERNAL, request); 43 | expect(error.message).toStrictEqual( 44 | `13 INTERNAL: makeUnaryRequest for 'customers.Customers.GetCustomer'` 45 | ); 46 | expect(error.code).toStrictEqual(status.INTERNAL); 47 | expect(error.metadata).toStrictEqual(request.metadata); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/makeBidiStreamRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { ServerDuplexStream, status } from "@grpc/grpc-js"; 2 | import { RequestError, ProtoRequest, StreamWriter } from "../src"; 3 | import { promisify } from "util"; 4 | import { 5 | Customer, 6 | getClient, 7 | makeBidiStreamRequest, 8 | startServer, 9 | wait, 10 | } from "./utils"; 11 | import { MockServiceError } from "./MockServiceError"; 12 | import { Readable } from "stream"; 13 | 14 | describe("makeBidiStreamRequest", () => { 15 | let RESPONSE_DELAY: number; 16 | let THROW_ERROR_RESPONSE: boolean; 17 | let activeRequest: ProtoRequest; 18 | 19 | beforeEach(async () => { 20 | THROW_ERROR_RESPONSE = false; 21 | 22 | const CUSTOMERS: Customer[] = [ 23 | { id: "github", name: "Github" }, 24 | { id: "npm", name: "NPM" }, 25 | { id: "jira", name: "JIRA" }, 26 | ]; 27 | const CUSTOMERS_HASH: { [id: string]: Customer } = {}; 28 | 29 | CUSTOMERS.forEach( 30 | (customer) => (CUSTOMERS_HASH[customer.id as string] = customer) 31 | ); 32 | 33 | const { client } = await startServer({ 34 | CreateCustomers: (call: ServerDuplexStream) => { 35 | if (THROW_ERROR_RESPONSE) { 36 | return call.destroy( 37 | new MockServiceError(status.INTERNAL, "Generic Service Error") 38 | ); 39 | } 40 | 41 | const writeBackRows: Customer[] = []; 42 | call.on("data", (row: Customer) => { 43 | if (!row || !row.id) { 44 | return call.destroy(new Error("Customer Not Found")); 45 | } 46 | 47 | CUSTOMERS_HASH[row.id] = row; 48 | CUSTOMERS.push(row); 49 | writeBackRows.push(row); 50 | }); 51 | 52 | call.on("end", () => { 53 | const timerid = setTimeout(async () => { 54 | for (const row of writeBackRows) { 55 | await promisify(call.write.bind(call))(row); 56 | } 57 | 58 | call.end(); 59 | }, RESPONSE_DELAY); 60 | 61 | call.on("cancelled", () => { 62 | if (timerid) { 63 | clearTimeout(timerid); 64 | } 65 | }); 66 | }); 67 | }, 68 | }); 69 | 70 | client.useMiddleware(async (req) => { 71 | activeRequest = req; 72 | }); 73 | }); 74 | 75 | test("should successfully request against the CreateCustomers method", async () => { 76 | const customers: Customer[] = [ 77 | { 78 | id: "circleci", 79 | name: "CircleCI", 80 | }, 81 | { 82 | id: "vscode", 83 | name: "VSCode", 84 | }, 85 | ]; 86 | const readCustomers: Customer[] = []; 87 | 88 | const request = await makeBidiStreamRequest( 89 | async (write, request) => { 90 | expect(request.isWritable).toStrictEqual(true); 91 | expect(request.isReadable).toStrictEqual(true); 92 | expect(request.isActive).toStrictEqual(true); 93 | 94 | await write(customers[0]); 95 | await write(customers[1]); 96 | }, 97 | async (row, _index, request) => { 98 | expect(request.isWritable).toStrictEqual(false); 99 | expect(request.isReadable).toStrictEqual(true); 100 | expect(request.isActive).toStrictEqual(true); 101 | 102 | readCustomers.push(row); 103 | } 104 | ); 105 | 106 | expect(request.isWritable).toStrictEqual(false); 107 | expect(request.isReadable).toStrictEqual(false); 108 | expect(request.isActive).toStrictEqual(false); 109 | 110 | expect(readCustomers).toEqual([ 111 | { 112 | id: "circleci", 113 | name: "CircleCI", 114 | }, 115 | { 116 | id: "vscode", 117 | name: "VSCode", 118 | }, 119 | ]); 120 | }); 121 | 122 | test("should wait for both read and write processing to complete before resolving the promise", async () => { 123 | const customers: Customer[] = [ 124 | { 125 | id: "circleci", 126 | name: "CircleCI", 127 | }, 128 | { 129 | id: "vscode", 130 | name: "VSCode", 131 | }, 132 | ]; 133 | const readCustomers: Customer[] = []; 134 | 135 | await makeBidiStreamRequest( 136 | async (write) => { 137 | await wait(15); 138 | await write(customers[0]); 139 | await wait(15); 140 | await write(customers[1]); 141 | }, 142 | async (row) => { 143 | await wait(15); 144 | readCustomers.push(row); 145 | } 146 | ); 147 | 148 | expect(readCustomers).toEqual([ 149 | { 150 | id: "circleci", 151 | name: "CircleCI", 152 | }, 153 | { 154 | id: "vscode", 155 | name: "VSCode", 156 | }, 157 | ]); 158 | }); 159 | 160 | test("should handle readable stream passed instead of stream writer", async () => { 161 | const customers: Customer[] = [ 162 | { 163 | id: "circleci", 164 | name: "CircleCI", 165 | }, 166 | { 167 | id: "vscode", 168 | name: "VSCode", 169 | }, 170 | ]; 171 | const readCustomers: Customer[] = []; 172 | 173 | await makeBidiStreamRequest(Readable.from(customers), async (row) => { 174 | readCustomers.push(row); 175 | }); 176 | 177 | expect(readCustomers).toEqual([ 178 | { 179 | id: "circleci", 180 | name: "CircleCI", 181 | }, 182 | { 183 | id: "vscode", 184 | name: "VSCode", 185 | }, 186 | ]); 187 | }); 188 | 189 | test("should ignore first try failure if the retry is successful", async () => { 190 | RESPONSE_DELAY = 1000; 191 | const customers: Customer[] = [ 192 | { 193 | id: "circleci", 194 | name: "CircleCI", 195 | }, 196 | { 197 | id: "vscode", 198 | name: "VSCode", 199 | }, 200 | ]; 201 | const readCustomers: Customer[] = []; 202 | 203 | const requestPromise = makeBidiStreamRequest( 204 | async (write) => { 205 | await write(customers[0]); 206 | await write(customers[1]); 207 | }, 208 | async (row) => { 209 | readCustomers.push(row); 210 | }, 211 | { timeout: 200, retryOptions: true } 212 | ); 213 | 214 | await wait(100); 215 | RESPONSE_DELAY = 0; 216 | 217 | const { error, responseErrors } = await requestPromise; 218 | expect(readCustomers).toEqual([ 219 | { 220 | id: "circleci", 221 | name: "CircleCI", 222 | }, 223 | { 224 | id: "vscode", 225 | name: "VSCode", 226 | }, 227 | ]); 228 | expect(error).toBeUndefined(); 229 | expect(responseErrors).toEqual([ 230 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 231 | ]); 232 | }); 233 | 234 | test("should propagate write sandbox errors", async () => { 235 | const { error } = await makeBidiStreamRequest( 236 | async () => { 237 | await wait(); 238 | throw new Error(`Mock Write Sandbox Error`); 239 | }, 240 | async () => { 241 | throw new Error(`Should never get to read processing`); 242 | } 243 | ); 244 | 245 | expect(error?.message).toEqual(`Mock Write Sandbox Error`); 246 | }); 247 | 248 | test("should propagate invalid write data errors", async () => { 249 | const { error } = await makeBidiStreamRequest( 250 | async (write) => { 251 | await wait(); 252 | await write({ id: 1234 } as never); 253 | }, 254 | async () => { 255 | throw new Error(`Should never get to read processing`); 256 | } 257 | ); 258 | 259 | expect(error?.message).toEqual(`id: string expected`); 260 | }); 261 | 262 | test("should throw error when trying to write on a closed sandbox", async () => { 263 | let writer: StreamWriter | undefined; 264 | 265 | await makeBidiStreamRequest( 266 | async (write) => { 267 | writer = write; 268 | 269 | await write({ 270 | id: "circleci", 271 | name: "CircleCI", 272 | }); 273 | }, 274 | async () => undefined 275 | ); 276 | 277 | await expect( 278 | (writer as StreamWriter)({ 279 | id: "vscode", 280 | name: "VSCode", 281 | }) 282 | ).rejects.toThrow( 283 | `The write stream has already closed for customers.Customers.CreateCustomers` 284 | ); 285 | }); 286 | 287 | test("should propagate read sandbox errors", async () => { 288 | const { error } = await makeBidiStreamRequest( 289 | async (write) => { 290 | await write({ 291 | id: "circleci", 292 | name: "CircleCI", 293 | }); 294 | }, 295 | async () => { 296 | throw new Error(`Mock Read Processing Error`); 297 | } 298 | ); 299 | 300 | expect(error?.message).toEqual(`Mock Read Processing Error`); 301 | }); 302 | 303 | test("should propagate timeout errors", async () => { 304 | RESPONSE_DELAY = 1000; 305 | const { error } = await makeBidiStreamRequest( 306 | async (write) => { 307 | await write({ id: "circleci", name: "CircleCI" }); 308 | }, 309 | async () => undefined, 310 | { timeout: 100 } 311 | ); 312 | 313 | expect(error?.message).toEqual(`4 DEADLINE_EXCEEDED: Deadline exceeded`); 314 | }); 315 | 316 | test("should handle service errors", async () => { 317 | THROW_ERROR_RESPONSE = true; 318 | const { error } = await makeBidiStreamRequest( 319 | async (write) => { 320 | await write({ id: "circleci", name: "CircleCI" }); 321 | }, 322 | async () => { 323 | throw new Error(`Should not get to streamReader`); 324 | } 325 | ); 326 | 327 | expect(error?.message).toEqual(`13 INTERNAL: Generic Service Error`); 328 | }); 329 | 330 | test("should ignore aborted requests", async () => { 331 | RESPONSE_DELAY = 1000; 332 | return new Promise((resolve, reject) => { 333 | const abortController = new AbortController(); 334 | makeBidiStreamRequest( 335 | async (write) => { 336 | await write({ id: "circleci", name: "CircleCI" }); 337 | }, 338 | async () => undefined, 339 | abortController 340 | ) 341 | .then(() => reject(new Error(`Should not have a successful return`))) 342 | .catch(() => reject(new Error(`Should not reject`))); 343 | 344 | setTimeout(() => { 345 | activeRequest.on("aborted", () => resolve()); 346 | abortController.abort(); 347 | }, 100); 348 | }); 349 | }); 350 | 351 | test("should propagate aborted error when configured too", async () => { 352 | RESPONSE_DELAY = 1000; 353 | getClient().clientSettings.rejectOnAbort = true; 354 | return new Promise((resolve, reject) => { 355 | const abortController = new AbortController(); 356 | makeBidiStreamRequest( 357 | async (write) => { 358 | await write({ id: "circleci", name: "CircleCI" }); 359 | }, 360 | async () => undefined, 361 | abortController 362 | ) 363 | .then(() => reject(new Error(`Should not have a successful return`))) 364 | .catch((e) => { 365 | try { 366 | expect(e).toBeInstanceOf(RequestError); 367 | expect((e as RequestError).details).toStrictEqual( 368 | `Cancelled makeBidiStreamRequest for 'customers.Customers.CreateCustomers'` 369 | ); 370 | resolve(); 371 | } catch (matchError) { 372 | reject(matchError); 373 | } 374 | }); 375 | 376 | setTimeout(() => abortController.abort(), 100); 377 | }); 378 | }); 379 | }); 380 | -------------------------------------------------------------------------------- /test/makeClientStreamRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { sendUnaryData, ServerReadableStream, status } from "@grpc/grpc-js"; 2 | import { StreamWriter } from "../src"; 3 | import { 4 | Customer, 5 | CustomersResponse, 6 | getClient, 7 | getClientStreamRequest, 8 | makeClientStreamRequest, 9 | startServer, 10 | wait, 11 | } from "./utils"; 12 | import { MockServiceError } from "./MockServiceError"; 13 | import { Readable } from "stream"; 14 | 15 | describe("makeClientStreamRequest", () => { 16 | let RESPONSE_DELAY: number; 17 | let THROW_ERROR_RESPONSE: boolean; 18 | 19 | beforeEach(async () => { 20 | THROW_ERROR_RESPONSE = false; 21 | RESPONSE_DELAY = 0; 22 | 23 | const CUSTOMERS: Customer[] = [ 24 | { id: "github", name: "Github" }, 25 | { id: "npm", name: "NPM" }, 26 | { id: "jira", name: "JIRA" }, 27 | ]; 28 | const CUSTOMERS_HASH: { [id: string]: Customer } = {}; 29 | 30 | CUSTOMERS.forEach( 31 | (customer) => (CUSTOMERS_HASH[customer.id as string] = customer) 32 | ); 33 | 34 | await startServer({ 35 | EditCustomer: ( 36 | call: ServerReadableStream, 37 | callback: sendUnaryData 38 | ) => { 39 | if (THROW_ERROR_RESPONSE) { 40 | return callback( 41 | new MockServiceError(status.INTERNAL, "Generic Service Error") 42 | ); 43 | } 44 | 45 | const customers: Customer[] = []; 46 | 47 | call.on("data", (row: Customer) => { 48 | if (!row || !row.id || !CUSTOMERS_HASH[row.id]) { 49 | return callback(new Error(`Customer ${row?.id} not found`), null); 50 | } 51 | 52 | CUSTOMERS_HASH[row.id].name = row.name; 53 | customers.push(CUSTOMERS_HASH[row.id]); 54 | }); 55 | 56 | call.on("end", () => { 57 | if (call.cancelled || call.destroyed) { 58 | return; 59 | } 60 | 61 | const timerid = setTimeout( 62 | () => callback(null, { customers }), 63 | RESPONSE_DELAY 64 | ); 65 | 66 | call.on("cancelled", () => { 67 | if (timerid) { 68 | clearTimeout(timerid); 69 | } 70 | }); 71 | }); 72 | }, 73 | }); 74 | }); 75 | 76 | test("should successfully request against the EditCustomer method", async () => { 77 | const request = await makeClientStreamRequest(async (write, request) => { 78 | expect(request.isReadable).toStrictEqual(false); 79 | expect(request.isWritable).toStrictEqual(true); 80 | expect(request.isActive).toStrictEqual(true); 81 | 82 | await write({ 83 | id: "github", 84 | name: "Github 2000", 85 | }); 86 | 87 | await write({ 88 | id: "npm", 89 | name: "NPM 2000", 90 | }); 91 | }); 92 | 93 | expect(request.isReadable).toStrictEqual(false); 94 | expect(request.isWritable).toStrictEqual(false); 95 | expect(request.isActive).toStrictEqual(false); 96 | 97 | expect(request.result).toEqual({ 98 | customers: [ 99 | { 100 | id: "github", 101 | name: "Github 2000", 102 | }, 103 | { 104 | id: "npm", 105 | name: "NPM 2000", 106 | }, 107 | ], 108 | }); 109 | }); 110 | 111 | test("should handle readable stream passed instead of writer sandbox", async () => { 112 | const { result } = await makeClientStreamRequest( 113 | Readable.from([ 114 | { id: "github", name: "Github 2000" }, 115 | { id: "npm", name: "NPM 2000" }, 116 | ]) 117 | ); 118 | 119 | expect(result).toEqual({ 120 | customers: [ 121 | { 122 | id: "github", 123 | name: "Github 2000", 124 | }, 125 | { 126 | id: "npm", 127 | name: "NPM 2000", 128 | }, 129 | ], 130 | }); 131 | }); 132 | 133 | test("should handle no writer sandbox passed (no data sent to server)", async () => { 134 | const { result, error } = await makeClientStreamRequest(); 135 | 136 | expect(result).toEqual({}); 137 | expect(error).toBeUndefined(); 138 | }); 139 | 140 | test("should ignore first try failure if the retry is successful", async () => { 141 | RESPONSE_DELAY = 1000; 142 | const request = getClientStreamRequest( 143 | async (write) => { 144 | await write({ 145 | id: "github", 146 | name: "Github 2000", 147 | }); 148 | 149 | await write({ 150 | id: "npm", 151 | name: "NPM 2000", 152 | }); 153 | }, 154 | { timeout: 200, retryOptions: true } 155 | ); 156 | 157 | await wait(100); 158 | RESPONSE_DELAY = 0; 159 | 160 | await request.waitForEnd(); 161 | expect(request.result).toEqual({ 162 | customers: [ 163 | { 164 | id: "github", 165 | name: "Github 2000", 166 | }, 167 | { 168 | id: "npm", 169 | name: "NPM 2000", 170 | }, 171 | ], 172 | }); 173 | expect(request.error).toBeUndefined(); 174 | expect(request.responseErrors).toEqual([ 175 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 176 | ]); 177 | }); 178 | 179 | test("should propagate write errors", async () => { 180 | const { error } = await makeClientStreamRequest(async () => { 181 | await wait(50); 182 | throw new Error(`Some Mock Write Sandbox Error`); 183 | }); 184 | 185 | expect(error?.message).toStrictEqual(`Some Mock Write Sandbox Error`); 186 | }); 187 | 188 | test("should propagate invalid write data errors", async () => { 189 | const { error } = await makeClientStreamRequest(async (write) => { 190 | await wait(50); 191 | await write({ id: 1234 } as never); 192 | }); 193 | 194 | expect(error?.message).toStrictEqual(`id: string expected`); 195 | }); 196 | 197 | test("should throw error when trying to write on a closed sandbox", async () => { 198 | let writer: StreamWriter | undefined; 199 | 200 | await makeClientStreamRequest(async (write) => { 201 | writer = write; 202 | await write({ 203 | id: "github", 204 | name: "Github 2000", 205 | }); 206 | }); 207 | 208 | await expect( 209 | (writer as StreamWriter)({ 210 | id: "npm", 211 | name: "NPM 2000", 212 | }) 213 | ).rejects.toThrow( 214 | `The write stream has already closed for customers.Customers.EditCustomer` 215 | ); 216 | }); 217 | 218 | test("should propagate timeout errors", async () => { 219 | RESPONSE_DELAY = 1000; 220 | const { error } = await makeClientStreamRequest( 221 | async (write) => { 222 | await write({ 223 | id: "github", 224 | name: "meow", 225 | }); 226 | }, 227 | { timeout: 100 } 228 | ); 229 | 230 | expect(error?.message).toEqual(`4 DEADLINE_EXCEEDED: Deadline exceeded`); 231 | }); 232 | 233 | test("should handle service errors", async () => { 234 | THROW_ERROR_RESPONSE = true; 235 | const { error } = await makeClientStreamRequest(async (write) => { 236 | await write({ 237 | id: "github", 238 | name: "meow", 239 | }); 240 | }); 241 | 242 | expect(error?.message).toEqual(`13 INTERNAL: Generic Service Error`); 243 | }); 244 | 245 | test("should ignore aborted requests", async () => { 246 | RESPONSE_DELAY = 1000; 247 | const abortController = new AbortController(); 248 | const request = getClientStreamRequest(async (write) => { 249 | await write({ 250 | id: "github", 251 | name: "meow", 252 | }); 253 | }, abortController); 254 | 255 | return new Promise((resolve, reject) => { 256 | request 257 | .waitForEnd() 258 | .then(() => reject(new Error(`Should not have a successful return`))) 259 | .catch(() => reject(new Error(`Should not reject`))); 260 | 261 | setTimeout(() => { 262 | request.on("aborted", () => resolve()); 263 | abortController.abort(); 264 | }, 100); 265 | }); 266 | }); 267 | 268 | test("should propagate aborted error when configured too", async () => { 269 | RESPONSE_DELAY = 1000; 270 | getClient().clientSettings.rejectOnAbort = true; 271 | const abortController = new AbortController(); 272 | const request = getClientStreamRequest(async (write) => { 273 | await write({ 274 | id: "github", 275 | name: "meow", 276 | }); 277 | }, abortController); 278 | 279 | await wait(100); 280 | abortController.abort(); 281 | 282 | await expect(request.waitForEnd()).rejects.toThrow( 283 | `Cancelled makeClientStreamRequest for 'customers.Customers.EditCustomer'` 284 | ); 285 | }); 286 | }); 287 | -------------------------------------------------------------------------------- /test/makeRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ServerUnaryCall, 3 | sendUnaryData, 4 | ServerDuplexStream, 5 | } from "@grpc/grpc-js"; 6 | import { ProtoClient, RequestMethodType } from "../src"; 7 | import { 8 | Customer, 9 | FindCustomersRequest, 10 | getClient, 11 | GetCustomerRequest, 12 | startServer, 13 | } from "./utils"; 14 | 15 | describe("makeRequest", () => { 16 | let client: ProtoClient; 17 | 18 | beforeEach(async () => { 19 | await startServer({ 20 | GetCustomer: ( 21 | _call: ServerUnaryCall, 22 | callback: sendUnaryData 23 | ) => { 24 | callback(null, { id: "github", name: "Github" }); 25 | }, 26 | 27 | CreateCustomers: (call: ServerDuplexStream) => { 28 | call.on("data", (row) => { 29 | call.write(row); 30 | }); 31 | 32 | call.on("end", () => { 33 | call.end(); 34 | }); 35 | }, 36 | }); 37 | 38 | client = getClient(); 39 | }); 40 | 41 | test("should be able to make a request by method only", async () => { 42 | const { result } = await client.makeRequest( 43 | "customers.Customers.GetCustomer" 44 | ); 45 | 46 | expect(result).toStrictEqual({ id: "github", name: "Github" }); 47 | }); 48 | 49 | test("should be able to make a request by params only", async () => { 50 | const customers: Customer[] = []; 51 | await client.makeRequest({ 52 | method: "customers.Customers.CreateCustomers", 53 | writerSandbox: async (write) => { 54 | await write({ id: "github", name: "Github" }); 55 | await write({ id: "npm", name: "NPM" }); 56 | }, 57 | streamReader: async (row) => { 58 | customers.push(row); 59 | }, 60 | }); 61 | 62 | expect(customers).toEqual([ 63 | { id: "github", name: "Github" }, 64 | { id: "npm", name: "NPM" }, 65 | ]); 66 | }); 67 | 68 | test("should only throw an error if attempting to safe guard method type", async () => { 69 | await expect( 70 | client.makeRequest({ 71 | method: "customers.Customers.GetCustomer", 72 | requestMethodType: RequestMethodType.BidiStreamRequest, 73 | }) 74 | ).rejects.toThrow( 75 | `makeBidiStreamRequest does not support method 'customers.Customers.GetCustomer', use makeUnaryRequest instead` 76 | ); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/makeServerStreamRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { ServerWritableStream, status } from "@grpc/grpc-js"; 2 | import { promisify } from "util"; 3 | import { 4 | Customer, 5 | FindCustomersRequest, 6 | getClient, 7 | getServerStreamRequest, 8 | makeServerStreamRequest, 9 | startServer, 10 | wait, 11 | } from "./utils"; 12 | import { MockServiceError } from "./MockServiceError"; 13 | 14 | describe("makeServerStreamRequest", () => { 15 | let RESPONSE_DELAY: number; 16 | let THROW_ERROR_RESPONSE: boolean; 17 | let TOGGLE_THROWN_ERROR: boolean; 18 | 19 | beforeEach(async () => { 20 | THROW_ERROR_RESPONSE = false; 21 | TOGGLE_THROWN_ERROR = false; 22 | RESPONSE_DELAY = 0; 23 | 24 | const CUSTOMERS: Customer[] = [ 25 | { id: "github", name: "Github" }, 26 | { id: "npm", name: "NPM" }, 27 | { id: "jira", name: "JIRA" }, 28 | ]; 29 | const CUSTOMERS_HASH: { [id: string]: Customer } = {}; 30 | 31 | CUSTOMERS.forEach( 32 | (customer) => (CUSTOMERS_HASH[customer.id as string] = customer) 33 | ); 34 | 35 | await startServer({ 36 | FindCustomers: ( 37 | call: ServerWritableStream 38 | ) => { 39 | if (THROW_ERROR_RESPONSE) { 40 | if (TOGGLE_THROWN_ERROR) { 41 | THROW_ERROR_RESPONSE = !THROW_ERROR_RESPONSE; 42 | } 43 | return call.destroy( 44 | new MockServiceError(status.INTERNAL, "Generic Service Error") 45 | ); 46 | } 47 | 48 | const timerid = setTimeout(async () => { 49 | for (const customer of CUSTOMERS) { 50 | await promisify(call.write.bind(call))(customer); 51 | } 52 | 53 | call.end(); 54 | }, RESPONSE_DELAY); 55 | 56 | call.on("cancelled", () => { 57 | if (timerid) { 58 | clearTimeout(timerid); 59 | } 60 | }); 61 | }, 62 | }); 63 | }); 64 | 65 | test("should successfully request against the FindCustomers method", async () => { 66 | const customers: Customer[] = []; 67 | 68 | const request = await makeServerStreamRequest( 69 | async (row, _index, request) => { 70 | expect(request.isReadable).toStrictEqual(true); 71 | expect(request.isWritable).toStrictEqual(false); 72 | expect(request.isActive).toStrictEqual(true); 73 | 74 | customers.push(row); 75 | } 76 | ); 77 | 78 | expect(request.isReadable).toStrictEqual(false); 79 | expect(request.isWritable).toStrictEqual(false); 80 | expect(request.isActive).toStrictEqual(false); 81 | 82 | expect(customers).toEqual([ 83 | { 84 | id: "github", 85 | name: "Github", 86 | }, 87 | { 88 | id: "npm", 89 | name: "NPM", 90 | }, 91 | { 92 | id: "jira", 93 | name: "JIRA", 94 | }, 95 | ]); 96 | }); 97 | 98 | test("should support empty data parameter", async () => { 99 | const customers: Customer[] = []; 100 | 101 | await makeServerStreamRequest(undefined as never, async (row) => { 102 | customers.push(row); 103 | }); 104 | 105 | expect(customers).toEqual([ 106 | { 107 | id: "github", 108 | name: "Github", 109 | }, 110 | { 111 | id: "npm", 112 | name: "NPM", 113 | }, 114 | { 115 | id: "jira", 116 | name: "JIRA", 117 | }, 118 | ]); 119 | }); 120 | 121 | test("should support not passing a stream reader", async () => { 122 | const customers: Customer[] = []; 123 | 124 | const request = getClient().getServerStreamRequest( 125 | "customers.Customers.FindCustomers" 126 | ); 127 | request.on("data", (row) => { 128 | customers.push(row); 129 | }); 130 | await request.waitForEnd(); 131 | 132 | expect(customers).toEqual([ 133 | { 134 | id: "github", 135 | name: "Github", 136 | }, 137 | { 138 | id: "npm", 139 | name: "NPM", 140 | }, 141 | { 142 | id: "jira", 143 | name: "JIRA", 144 | }, 145 | ]); 146 | }); 147 | 148 | test("should wait for all read processing to complete before resolving promise", async () => { 149 | const customers: Customer[] = []; 150 | 151 | await makeServerStreamRequest(async (row) => { 152 | await wait(15); 153 | customers.push(row); 154 | }); 155 | 156 | expect(customers).toEqual([ 157 | { 158 | id: "github", 159 | name: "Github", 160 | }, 161 | { 162 | id: "npm", 163 | name: "NPM", 164 | }, 165 | { 166 | id: "jira", 167 | name: "JIRA", 168 | }, 169 | ]); 170 | }); 171 | 172 | test("should ignore first try failure if the retry is successful", async () => { 173 | RESPONSE_DELAY = 1000; 174 | const customers: Customer[] = []; 175 | const request = getServerStreamRequest( 176 | {}, 177 | async (row) => { 178 | customers.push(row); 179 | }, 180 | { timeout: 200, retryOptions: true } 181 | ); 182 | 183 | await wait(100); 184 | RESPONSE_DELAY = 0; 185 | 186 | await request.waitForEnd(); 187 | expect(customers).toEqual([ 188 | { 189 | id: "github", 190 | name: "Github", 191 | }, 192 | { 193 | id: "npm", 194 | name: "NPM", 195 | }, 196 | { 197 | id: "jira", 198 | name: "JIRA", 199 | }, 200 | ]); 201 | expect(request.error).toBeUndefined(); 202 | expect(request.responseErrors).toEqual([ 203 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 204 | ]); 205 | }); 206 | 207 | test("should propagate validation errors", async () => { 208 | const { error } = await makeServerStreamRequest( 209 | { name: 1234 } as never, 210 | async () => { 211 | throw new Error(`Should not get to streamReader`); 212 | } 213 | ); 214 | 215 | expect(error?.message).toEqual(`name: string expected`); 216 | }); 217 | 218 | test("should propagate read processing errors", async () => { 219 | const { error } = await makeServerStreamRequest(async () => { 220 | throw new Error(`Mock Read Processing Error`); 221 | }); 222 | 223 | expect(error?.message).toEqual(`Mock Read Processing Error`); 224 | }); 225 | 226 | test("should propagate timeout errors", async () => { 227 | RESPONSE_DELAY = 1000; 228 | const { error } = await makeServerStreamRequest( 229 | {}, 230 | async () => { 231 | throw new Error(`Should not get to streamReader`); 232 | }, 233 | { timeout: 100 } 234 | ); 235 | 236 | expect(error?.message).toEqual(`4 DEADLINE_EXCEEDED: Deadline exceeded`); 237 | }); 238 | 239 | test("should handle service errors", async () => { 240 | THROW_ERROR_RESPONSE = true; 241 | 242 | const { error } = await makeServerStreamRequest(async () => { 243 | throw new Error(`Should not get to streamReader`); 244 | }); 245 | 246 | expect(error?.message).toEqual(`13 INTERNAL: Generic Service Error`); 247 | }); 248 | 249 | test("should ignore aborted requests", async () => { 250 | RESPONSE_DELAY = 1000; 251 | const abortController = new AbortController(); 252 | const request = getServerStreamRequest( 253 | {}, 254 | async () => { 255 | throw new Error(`Should not get to streamReader`); 256 | }, 257 | abortController 258 | ); 259 | 260 | return new Promise((resolve, reject) => { 261 | request 262 | .waitForEnd() 263 | .then(() => reject(new Error(`Should not have a successful return`))) 264 | .catch(() => reject(new Error(`Should not reject`))); 265 | 266 | setTimeout(() => { 267 | request.on("aborted", () => resolve()); 268 | abortController.abort(); 269 | }, 100); 270 | }); 271 | }); 272 | 273 | test("should propagate aborted error when configured too", async () => { 274 | RESPONSE_DELAY = 1000; 275 | getClient().clientSettings.rejectOnAbort = true; 276 | const abortController = new AbortController(); 277 | const request = getServerStreamRequest( 278 | {}, 279 | async () => { 280 | throw new Error(`Should not get to streamReader`); 281 | }, 282 | abortController 283 | ); 284 | 285 | await wait(100); 286 | abortController.abort(); 287 | 288 | await expect(request.waitForEnd()).rejects.toThrow( 289 | `Cancelled makeServerStreamRequest for 'customers.Customers.FindCustomers'` 290 | ); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /test/makeUnaryRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { ServerUnaryCall, sendUnaryData, status } from "@grpc/grpc-js"; 2 | import { 3 | Customer, 4 | getClient, 5 | GetCustomerRequest, 6 | getUnaryRequest, 7 | makeUnaryRequest, 8 | startServer, 9 | wait, 10 | } from "./utils"; 11 | import { MockServiceError } from "./MockServiceError"; 12 | 13 | describe("makeUnaryRequest", () => { 14 | let RESPONSE_DELAY: number; 15 | let THROW_ERROR_RESPONSE: boolean; 16 | 17 | beforeEach(async () => { 18 | THROW_ERROR_RESPONSE = false; 19 | RESPONSE_DELAY = 0; 20 | 21 | const CUSTOMERS: Customer[] = [ 22 | { id: "github", name: "Github" }, 23 | { id: "npm", name: "NPM" }, 24 | { id: "jira", name: "JIRA" }, 25 | ]; 26 | const CUSTOMERS_HASH: { [id: string]: Customer } = {}; 27 | 28 | CUSTOMERS.forEach( 29 | (customer) => (CUSTOMERS_HASH[customer.id as string] = customer) 30 | ); 31 | 32 | await startServer({ 33 | GetCustomer: ( 34 | call: ServerUnaryCall, 35 | callback: sendUnaryData 36 | ) => { 37 | if (THROW_ERROR_RESPONSE) { 38 | callback( 39 | new MockServiceError(status.INTERNAL, "Generic Service Error") 40 | ); 41 | } else if (!call.request.id) { 42 | callback(null, CUSTOMERS_HASH.github); 43 | } else if (CUSTOMERS_HASH[call.request.id]) { 44 | const customer = CUSTOMERS_HASH[call.request.id]; 45 | const timerid = setTimeout( 46 | () => callback(null, customer), 47 | RESPONSE_DELAY 48 | ); 49 | call.on("cancelled", () => { 50 | if (timerid) { 51 | clearTimeout(timerid); 52 | } 53 | }); 54 | } else { 55 | callback(new Error("Customer Not Found")); 56 | } 57 | }, 58 | }); 59 | }); 60 | 61 | test("should successfully request against the GetCustomer method", async () => { 62 | const request = await makeUnaryRequest({ 63 | id: "github", 64 | }); 65 | 66 | expect(request.result).toEqual({ 67 | id: "github", 68 | name: "Github", 69 | }); 70 | }); 71 | 72 | test("should support no data passed", async () => { 73 | const request = await makeUnaryRequest(); 74 | 75 | expect(request.result).toEqual({ 76 | id: "github", 77 | name: "Github", 78 | }); 79 | }); 80 | 81 | test("should ignore first try failure if the retry is successful", async () => { 82 | RESPONSE_DELAY = 1000; 83 | const request = getUnaryRequest( 84 | { id: "github" }, 85 | { timeout: 200, retryOptions: true } 86 | ); 87 | 88 | await wait(100); 89 | RESPONSE_DELAY = 0; 90 | 91 | await request.waitForEnd(); 92 | expect(request.result).toEqual({ 93 | id: "github", 94 | name: "Github", 95 | }); 96 | expect(request.error).toBeUndefined(); 97 | expect(request.responseErrors).toEqual([ 98 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 99 | ]); 100 | }); 101 | 102 | test("should throw when passing invalid data", async () => { 103 | const { error } = await makeUnaryRequest({ id: 1234 } as never, { 104 | timeout: 100, 105 | }); 106 | 107 | expect(error?.message).toStrictEqual(`id: string expected`); 108 | }); 109 | 110 | test("should propagate timeout errors", async () => { 111 | RESPONSE_DELAY = 1000; 112 | const { error } = await makeUnaryRequest( 113 | { id: "github" }, 114 | { timeout: 100 } 115 | ); 116 | 117 | expect(error?.message).toStrictEqual( 118 | `4 DEADLINE_EXCEEDED: Deadline exceeded` 119 | ); 120 | }); 121 | 122 | test("should handle service errors", async () => { 123 | THROW_ERROR_RESPONSE = true; 124 | const { error } = await makeUnaryRequest({ id: "github" }); 125 | 126 | expect(error?.message).toStrictEqual(`13 INTERNAL: Generic Service Error`); 127 | }); 128 | 129 | test("should ignore aborted requests", async () => { 130 | RESPONSE_DELAY = 1000; 131 | const abortController = new AbortController(); 132 | const request = getUnaryRequest({ id: "github" }, abortController); 133 | 134 | return new Promise((resolve, reject) => { 135 | request 136 | .waitForEnd() 137 | .then(() => reject(new Error(`Should not have a successful return`))) 138 | .catch(() => reject(new Error(`Should not reject`))); 139 | 140 | setTimeout(() => { 141 | request.on("aborted", () => resolve()); 142 | abortController.abort(); 143 | }, 100); 144 | }); 145 | }); 146 | 147 | test("should propagate aborted error when configured too", async () => { 148 | RESPONSE_DELAY = 1000; 149 | getClient().clientSettings.rejectOnAbort = true; 150 | 151 | const abortController = new AbortController(); 152 | const request = getUnaryRequest({ id: "github" }, abortController); 153 | 154 | await wait(100); 155 | abortController.abort(); 156 | 157 | await expect(request.waitForEnd()).rejects.toThrow( 158 | `Cancelled makeUnaryRequest for 'customers.Customers.GetCustomer'` 159 | ); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /test/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ServerUnaryCall, 3 | sendUnaryData, 4 | status, 5 | Metadata, 6 | ServerReadableStream, 7 | ServerWritableStream, 8 | ServerDuplexStream, 9 | } from "@grpc/grpc-js"; 10 | import { 11 | Customer, 12 | CustomersResponse, 13 | FindCustomersRequest, 14 | GetCustomerRequest, 15 | makeBidiStreamRequest, 16 | makeClientStreamRequest, 17 | makeServerStreamRequest, 18 | makeUnaryRequest, 19 | startServer, 20 | } from "./utils"; 21 | import { MockServiceError } from "./MockServiceError"; 22 | 23 | describe("metadata", () => { 24 | const getResponseMetadata = () => { 25 | const metadata = new Metadata(); 26 | metadata.set("updated_auth_token", "bazboo"); 27 | return metadata; 28 | }; 29 | 30 | const getTrailingMetadata = () => { 31 | const metadata = new Metadata(); 32 | metadata.set("trailing_metadata", "trailer-foobar"); 33 | return metadata; 34 | }; 35 | 36 | beforeEach(async () => { 37 | await startServer({ 38 | GetCustomer: ( 39 | call: ServerUnaryCall, 40 | callback: sendUnaryData 41 | ) => { 42 | if (call.metadata.get("auth_token")?.[0] !== "foobar") { 43 | callback( 44 | new MockServiceError(status.INTERNAL, "Missing auth_token metadata") 45 | ); 46 | } else { 47 | call.sendMetadata(getResponseMetadata()); 48 | callback( 49 | null, 50 | { id: "github", name: "Github" }, 51 | getTrailingMetadata() 52 | ); 53 | } 54 | }, 55 | 56 | EditCustomer: ( 57 | call: ServerReadableStream, 58 | callback: sendUnaryData 59 | ) => { 60 | if (call.metadata.get("auth_token")?.[0] !== "foobar") { 61 | return callback( 62 | new MockServiceError(status.INTERNAL, "Missing auth_token metadata") 63 | ); 64 | } 65 | 66 | const customers: Customer[] = []; 67 | call.on("data", (row) => customers.push(row)); 68 | call.on("end", () => { 69 | call.sendMetadata(getResponseMetadata()); 70 | callback(null, { customers }, getTrailingMetadata()); 71 | }); 72 | }, 73 | 74 | FindCustomers: ( 75 | call: ServerWritableStream 76 | ) => { 77 | if (call.metadata.get("auth_token")?.[0] !== "foobar") { 78 | return call.destroy( 79 | new MockServiceError(status.INTERNAL, "Missing auth_token metadata") 80 | ); 81 | } 82 | 83 | call.sendMetadata(getResponseMetadata()); 84 | call.write({ id: "github", name: "Github" }, () => { 85 | call.write({ id: "npm", name: "NPM" }, () => { 86 | call.end(getTrailingMetadata()); 87 | }); 88 | }); 89 | }, 90 | 91 | CreateCustomers: (call: ServerDuplexStream) => { 92 | if (call.metadata.get("auth_token")?.[0] !== "foobar") { 93 | return call.destroy( 94 | new MockServiceError(status.INTERNAL, "Missing auth_token metadata") 95 | ); 96 | } 97 | 98 | let firstWrite = true; 99 | call.on("data", (row) => { 100 | if (call.writable) { 101 | if (firstWrite) { 102 | call.sendMetadata(getResponseMetadata()); 103 | firstWrite = false; 104 | } 105 | 106 | call.write(row); 107 | } 108 | }); 109 | 110 | call.on("end", () => { 111 | call.end(getTrailingMetadata()); 112 | }); 113 | }, 114 | }); 115 | }); 116 | 117 | describe("Unary Request", () => { 118 | test("should convert object to metadata and verify the updated auth token coming back from the server", async () => { 119 | const request = await makeUnaryRequest( 120 | { id: "github" }, 121 | { 122 | metadata: { 123 | auth_token: "foobar", 124 | companies: ["github", "npm", "circleci"], 125 | }, 126 | } 127 | ); 128 | expect(request.metadata.get("auth_token")).toEqual(["foobar"]); 129 | expect(request.metadata.get("companies")).toEqual([ 130 | "github", 131 | "npm", 132 | "circleci", 133 | ]); 134 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 135 | "bazboo", 136 | ]); 137 | expect(request.trailingMetadata?.get("trailing_metadata")).toEqual([ 138 | "trailer-foobar", 139 | ]); 140 | }); 141 | 142 | test("should verify the updated auth token through a metadata instance", async () => { 143 | const metadata = new Metadata(); 144 | metadata.set("auth_token", "foobar"); 145 | 146 | const request = await makeUnaryRequest({ id: "github" }, { metadata }); 147 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 148 | "bazboo", 149 | ]); 150 | }); 151 | 152 | test("should fail the request if auth token is invalid in metadata", async () => { 153 | const { error } = await makeUnaryRequest( 154 | { id: "github" }, 155 | { metadata: { auth_token: "barbaz" } } 156 | ); 157 | 158 | expect(error?.message).toStrictEqual( 159 | `13 INTERNAL: Missing auth_token metadata` 160 | ); 161 | }); 162 | }); 163 | 164 | describe("Client Stream Request", () => { 165 | test("should convert object to metadata and verify the updated auth token coming back from the server", async () => { 166 | const request = await makeClientStreamRequest( 167 | async (write, request) => { 168 | // Trailing metadata should not be set until request completes 169 | expect(request.trailingMetadata).toStrictEqual(undefined); 170 | await write({ id: "github", name: "Github" }); 171 | await write({ id: "npm", name: "NPM" }); 172 | }, 173 | { 174 | metadata: { 175 | auth_token: "foobar", 176 | companies: ["github", "npm", "circleci"], 177 | }, 178 | } 179 | ); 180 | expect(request.metadata.get("auth_token")).toEqual(["foobar"]); 181 | expect(request.metadata.get("companies")).toEqual([ 182 | "github", 183 | "npm", 184 | "circleci", 185 | ]); 186 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 187 | "bazboo", 188 | ]); 189 | expect(request.trailingMetadata?.get("trailing_metadata")).toEqual([ 190 | "trailer-foobar", 191 | ]); 192 | }); 193 | 194 | test("should verify the updated auth token through a metadata instance", async () => { 195 | const metadata = new Metadata(); 196 | metadata.set("auth_token", "foobar"); 197 | 198 | const request = await makeClientStreamRequest( 199 | async (write) => { 200 | await write({ id: "github", name: "Github" }); 201 | await write({ id: "npm", name: "NPM" }); 202 | }, 203 | { metadata } 204 | ); 205 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 206 | "bazboo", 207 | ]); 208 | }); 209 | 210 | test("should fail the request if auth token is invalid in metadata", async () => { 211 | const { error } = await makeClientStreamRequest( 212 | async (write) => { 213 | await write({ id: "github", name: "Github" }); 214 | await write({ id: "npm", name: "NPM" }); 215 | }, 216 | { metadata: { auth_token: "barbaz" } } 217 | ); 218 | 219 | expect(error?.message).toStrictEqual( 220 | `13 INTERNAL: Missing auth_token metadata` 221 | ); 222 | }); 223 | }); 224 | 225 | describe("Server Stream Request", () => { 226 | test("should convert object to metadata and verify the updated auth token coming back from the server", async () => { 227 | const request = await makeServerStreamRequest( 228 | {}, 229 | async (_data, _index, request) => { 230 | // Trailing metadata should not be set until request completes 231 | expect(request.trailingMetadata).toStrictEqual(undefined); 232 | }, 233 | { 234 | metadata: { 235 | auth_token: "foobar", 236 | companies: ["github", "npm", "circleci"], 237 | }, 238 | } 239 | ); 240 | expect(request.metadata.get("auth_token")).toEqual(["foobar"]); 241 | expect(request.metadata.get("companies")).toEqual([ 242 | "github", 243 | "npm", 244 | "circleci", 245 | ]); 246 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 247 | "bazboo", 248 | ]); 249 | expect(request.trailingMetadata?.get("trailing_metadata")).toEqual([ 250 | "trailer-foobar", 251 | ]); 252 | }); 253 | 254 | test("should verify the updated auth token through a metadata instance", async () => { 255 | const metadata = new Metadata(); 256 | metadata.set("auth_token", "foobar"); 257 | 258 | const request = await makeServerStreamRequest({}, async () => undefined, { 259 | metadata, 260 | }); 261 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 262 | "bazboo", 263 | ]); 264 | }); 265 | 266 | test("should fail the request if auth token is invalid in metadata", async () => { 267 | const { error } = await makeServerStreamRequest( 268 | {}, 269 | async () => undefined, 270 | { 271 | metadata: { auth_token: "barbaz" }, 272 | } 273 | ); 274 | 275 | expect(error?.message).toStrictEqual( 276 | `13 INTERNAL: Missing auth_token metadata` 277 | ); 278 | }); 279 | }); 280 | 281 | describe("Bidi Stream Request", () => { 282 | test("should convert object to metadata and verify the updated auth token coming back from the server", async () => { 283 | const request = await makeBidiStreamRequest( 284 | async (write, request) => { 285 | // Trailing metadata should not be set until request completes 286 | expect(request.trailingMetadata).toStrictEqual(undefined); 287 | await write({ id: "github", name: "Github" }); 288 | await write({ id: "npm", name: "NPM" }); 289 | }, 290 | async (_data, _index, request) => { 291 | // Trailing metadata should not be set until request completes 292 | expect(request.trailingMetadata).toStrictEqual(undefined); 293 | }, 294 | { 295 | metadata: { 296 | auth_token: "foobar", 297 | companies: ["github", "npm", "circleci"], 298 | }, 299 | } 300 | ); 301 | expect(request.metadata.get("auth_token")).toEqual(["foobar"]); 302 | expect(request.metadata.get("companies")).toEqual([ 303 | "github", 304 | "npm", 305 | "circleci", 306 | ]); 307 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 308 | "bazboo", 309 | ]); 310 | expect(request.trailingMetadata?.get("trailing_metadata")).toEqual([ 311 | "trailer-foobar", 312 | ]); 313 | }); 314 | 315 | test("should verify the updated auth token through a metadata instance", async () => { 316 | const metadata = new Metadata(); 317 | metadata.set("auth_token", "foobar"); 318 | 319 | const request = await makeBidiStreamRequest( 320 | async (write) => { 321 | await write({ id: "github", name: "Github" }); 322 | await write({ id: "npm", name: "NPM" }); 323 | }, 324 | async () => undefined, 325 | { 326 | metadata, 327 | } 328 | ); 329 | expect(request.responseMetadata?.get(`updated_auth_token`)).toEqual([ 330 | "bazboo", 331 | ]); 332 | }); 333 | 334 | test("should fail the request if auth token is invalid in metadata", async () => { 335 | const { error } = await makeBidiStreamRequest( 336 | async (write) => { 337 | await write({ id: "github", name: "Github" }); 338 | await write({ id: "npm", name: "NPM" }); 339 | }, 340 | async () => undefined, 341 | { 342 | metadata: { auth_token: "barbaz" }, 343 | } 344 | ); 345 | 346 | expect(error?.message).toStrictEqual( 347 | `13 INTERNAL: Missing auth_token metadata` 348 | ); 349 | }); 350 | }); 351 | }); 352 | -------------------------------------------------------------------------------- /test/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { ServerUnaryCall, sendUnaryData } from "@grpc/grpc-js"; 2 | import { ProtoRequest } from "../src"; 3 | import { 4 | Customer, 5 | getClient, 6 | GetCustomerRequest, 7 | makeUnaryRequest, 8 | startServer, 9 | } from "./utils"; 10 | 11 | describe("metadata", () => { 12 | let activeRequest: ProtoRequest; 13 | let thrownEntity: Error | string | object | undefined; 14 | let SHOULD_ABORT: boolean; 15 | 16 | beforeEach(async () => { 17 | thrownEntity = undefined; 18 | SHOULD_ABORT = false; 19 | 20 | const { client } = await startServer({ 21 | GetCustomer: ( 22 | call: ServerUnaryCall, 23 | callback: sendUnaryData 24 | ) => { 25 | if (call.request && call.request.id === "github") { 26 | callback(null, { id: "github", name: "Github" }); 27 | } else { 28 | callback(new Error(`Customer Not Found`)); 29 | } 30 | }, 31 | }); 32 | 33 | client.useMiddleware(async (req) => { 34 | activeRequest = req; 35 | 36 | const token = req.metadata.get("auth_token")[0]; 37 | 38 | // Auto add auth_token to the metadata if not set 39 | if (!token) { 40 | req.metadata.set("auth_token", "foobar"); 41 | } 42 | 43 | if (thrownEntity !== undefined) { 44 | throw thrownEntity; 45 | } else if (SHOULD_ABORT) { 46 | req.abort(); 47 | } 48 | }); 49 | }); 50 | 51 | test("should verify auth token gets auto added by middleware", async () => { 52 | const request = await makeUnaryRequest({ id: "github" }); 53 | expect(request.metadata.get("auth_token")).toEqual(["foobar"]); 54 | expect(request).toStrictEqual(activeRequest); 55 | }); 56 | 57 | test("should fail the request if an error is thrown in the middleware", async () => { 58 | thrownEntity = new Error(`Mock Middleware Error`); 59 | 60 | const { error } = await makeUnaryRequest({ id: "github" }); 61 | expect(error?.message).toStrictEqual(`Mock Middleware Error`); 62 | }); 63 | 64 | test("should fail the request if a string is thrown in the middleware", async () => { 65 | thrownEntity = `Mock String Middleware Error`; 66 | 67 | const { error } = await makeUnaryRequest({ id: "github" }); 68 | expect(error?.message).toStrictEqual(`Mock String Middleware Error`); 69 | }); 70 | 71 | test("should fail the request anything is thrown in the middleware", async () => { 72 | thrownEntity = { custom: "Some Custom Thrown Object" }; 73 | 74 | const { error } = await makeUnaryRequest({ id: "github" }); 75 | expect(error?.message).toStrictEqual(`Unknown Middleware Error`); 76 | }); 77 | 78 | test("should not run a request at all if aborted during middleware", async () => { 79 | SHOULD_ABORT = true; 80 | getClient().clientSettings.rejectOnError = false; 81 | const request = await makeUnaryRequest({ id: "github" }); 82 | expect(request.error?.message).toStrictEqual( 83 | `Cancelled makeUnaryRequest for 'customers.Customers.GetCustomer'` 84 | ); 85 | expect(request.timing.attempts.length).toStrictEqual(0); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/pipe.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sendUnaryData, 3 | ServerReadableStream, 4 | ServerWritableStream, 5 | status, 6 | } from "@grpc/grpc-js"; 7 | import { EventEmitter, Readable } from "stream"; 8 | import { promisify } from "util"; 9 | import { ProtoClient } from "../src"; 10 | import { MockServiceError } from "./MockServiceError"; 11 | import { 12 | Customer, 13 | CustomersResponse, 14 | FindCustomersRequest, 15 | getClient, 16 | getServerStreamRequest, 17 | makeClientStreamRequest, 18 | startServer, 19 | wait, 20 | } from "./utils"; 21 | 22 | describe("pipe", () => { 23 | let client: ProtoClient; 24 | let THROW_FIND_ERROR: MockServiceError | undefined; 25 | 26 | beforeEach(async () => { 27 | THROW_FIND_ERROR = undefined; 28 | 29 | await startServer({ 30 | EditCustomer: ( 31 | call: ServerReadableStream, 32 | callback: sendUnaryData 33 | ) => { 34 | const customers: Customer[] = []; 35 | call.on("data", (row: Customer) => { 36 | customers.push(row); 37 | }); 38 | 39 | call.on("end", () => { 40 | callback(null, { customers }); 41 | }); 42 | }, 43 | 44 | FindCustomers: async ( 45 | call: ServerWritableStream 46 | ) => { 47 | if (THROW_FIND_ERROR) { 48 | return call.destroy(THROW_FIND_ERROR); 49 | } 50 | 51 | const CUSTOMERS: Customer[] = [ 52 | { id: "github", name: "Github" }, 53 | { id: "npm", name: "NPM" }, 54 | { id: "circleci", name: "CircleCI" }, 55 | ]; 56 | 57 | for (const customer of CUSTOMERS) { 58 | await promisify(call.write.bind(call))(customer); 59 | } 60 | 61 | call.end(); 62 | }, 63 | }); 64 | 65 | client = getClient(); 66 | }); 67 | 68 | test("should handle readable stream passed instead of writer sandbox", async () => { 69 | const { result } = await client.makeRequest({ 70 | method: "customers.Customers.EditCustomer", 71 | pipeStream: Readable.from([ 72 | { id: "github", name: "Github" }, 73 | { id: "npm", name: "NPM" }, 74 | ]), 75 | }); 76 | 77 | expect(result).toEqual({ 78 | customers: [ 79 | { 80 | id: "github", 81 | name: "Github", 82 | }, 83 | { 84 | id: "npm", 85 | name: "NPM", 86 | }, 87 | ], 88 | }); 89 | }); 90 | 91 | test("should handle piping another client request", async () => { 92 | const readStreamRequest = getServerStreamRequest(); 93 | const { result } = await makeClientStreamRequest(readStreamRequest); 94 | expect(result).toEqual({ 95 | customers: [ 96 | { id: "github", name: "Github" }, 97 | { id: "npm", name: "NPM" }, 98 | { id: "circleci", name: "CircleCI" }, 99 | ], 100 | }); 101 | }); 102 | 103 | test("should handle piped errors from another client request", async () => { 104 | THROW_FIND_ERROR = new MockServiceError( 105 | status.INTERNAL, 106 | "Mock Find Customers Error" 107 | ); 108 | const readStreamRequest = getServerStreamRequest(); 109 | const { error, result } = await makeClientStreamRequest(readStreamRequest); 110 | expect(error).toBeInstanceOf(Error); 111 | expect(error?.message).toEqual(`13 INTERNAL: Mock Find Customers Error`); 112 | expect(result).toBeUndefined(); 113 | }); 114 | 115 | test("should handle piped stream errors", async () => { 116 | const stream = new EventEmitter(); 117 | const request = client.getRequest({ 118 | method: "customers.Customers.EditCustomer", 119 | pipeStream: stream, 120 | }); 121 | 122 | request.on("error", () => undefined); 123 | stream.on("error", () => undefined); 124 | 125 | await wait(); 126 | stream.emit("error", new Error("Mock Pipe Error")); 127 | 128 | await request.waitForEnd(); 129 | expect(request.error?.message).toStrictEqual(`Mock Pipe Error`); 130 | }); 131 | 132 | test("should handle unknown piped stream errors", async () => { 133 | const stream = new EventEmitter(); 134 | const request = client.getRequest({ 135 | method: "customers.Customers.EditCustomer", 136 | pipeStream: stream, 137 | }); 138 | 139 | await wait(); 140 | stream.emit("error", "Mock Pipe String Error"); 141 | 142 | await request.waitForEnd(); 143 | expect(request.error?.message).toStrictEqual(`Pipe stream error`); 144 | }); 145 | 146 | describe("transform", () => { 147 | test("should handle piping a transformed request to another client request", async () => { 148 | const readStreamRequest = getServerStreamRequest(); 149 | const { result } = await makeClientStreamRequest( 150 | readStreamRequest.transform(async (data) => { 151 | return { id: data.id, name: data.name?.toUpperCase() }; 152 | }) 153 | ); 154 | expect(result).toEqual({ 155 | customers: [ 156 | { id: "github", name: "GITHUB" }, 157 | { id: "npm", name: "NPM" }, 158 | { id: "circleci", name: "CIRCLECI" }, 159 | ], 160 | }); 161 | }); 162 | 163 | test("should handle a delay in transforming", async () => { 164 | const readStreamRequest = getServerStreamRequest(); 165 | const { result } = await makeClientStreamRequest( 166 | readStreamRequest.transform(async (data) => { 167 | await wait(5); 168 | return { id: data.id, name: data.name?.toUpperCase() }; 169 | }) 170 | ); 171 | expect(result).toEqual({ 172 | customers: [ 173 | { id: "github", name: "GITHUB" }, 174 | { id: "npm", name: "NPM" }, 175 | { id: "circleci", name: "CIRCLECI" }, 176 | ], 177 | }); 178 | }); 179 | 180 | test("should handle errors from the piped request", async () => { 181 | THROW_FIND_ERROR = new MockServiceError( 182 | status.INTERNAL, 183 | `Mock Piped Request Error` 184 | ); 185 | const readStreamRequest = getServerStreamRequest(); 186 | const { result, error } = await makeClientStreamRequest( 187 | readStreamRequest.transform(async (data) => { 188 | await wait(5); 189 | return { id: data.id, name: data.name?.toUpperCase() }; 190 | }) 191 | ); 192 | expect(error).toBeInstanceOf(Error); 193 | expect(error?.message).toStrictEqual( 194 | `13 INTERNAL: Mock Piped Request Error` 195 | ); 196 | expect(result).toBeUndefined(); 197 | }); 198 | 199 | test("should handle transform errors", async () => { 200 | const readStreamRequest = getServerStreamRequest(); 201 | const { result, error } = await makeClientStreamRequest( 202 | readStreamRequest.transform(async () => { 203 | await wait(5); 204 | throw new Error(`Mock Transform Error`); 205 | }) 206 | ); 207 | expect(error).toBeInstanceOf(Error); 208 | expect(error?.message).toStrictEqual(`Mock Transform Error`); 209 | expect(result).toBeUndefined(); 210 | }); 211 | 212 | test("should ignore all messages after a transform error", async () => { 213 | let firstTransform = true; 214 | const readStreamRequest = getServerStreamRequest(); 215 | const { result, error } = await makeClientStreamRequest( 216 | readStreamRequest.transform(async (data) => { 217 | if (firstTransform) { 218 | firstTransform = false; 219 | await wait(10); 220 | throw new Error(`Mock Delayed Transform Error`); 221 | } else { 222 | await wait(20); 223 | return { id: data.id, name: data.name?.toUpperCase() }; 224 | } 225 | }) 226 | ); 227 | // Wait for the delayed transforms to complete 228 | await wait(30); 229 | expect(error).toBeInstanceOf(Error); 230 | expect(error?.message).toStrictEqual(`Mock Delayed Transform Error`); 231 | expect(result).toBeUndefined(); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/protos/customers.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package customers; 4 | 5 | message Customer { 6 | string id = 1; 7 | string name = 2; 8 | } 9 | 10 | message GetCustomerRequest { 11 | string id = 1; 12 | } 13 | 14 | message FindCustomersRequest { 15 | string name = 1; 16 | } 17 | 18 | message CustomersResponse { 19 | repeated Customer customers = 1; 20 | } 21 | 22 | service Customers { 23 | rpc GetCustomer (GetCustomerRequest) returns (Customer); 24 | rpc FindCustomers (FindCustomersRequest) returns (stream Customer); 25 | rpc EditCustomer (stream Customer) returns (CustomersResponse); 26 | rpc CreateCustomers (stream Customer) returns (stream Customer); 27 | } 28 | -------------------------------------------------------------------------------- /test/protos/products.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package v1.products.transport; 4 | 5 | message Product { 6 | string id = 1; 7 | string name = 2; 8 | } 9 | 10 | message ProductRequest { 11 | string id = 1; 12 | string name = 2; 13 | } 14 | 15 | service TransportationService { 16 | rpc GetProduct (ProductRequest) returns (Product); 17 | } 18 | -------------------------------------------------------------------------------- /test/retryOptions.test.ts: -------------------------------------------------------------------------------- 1 | import { ServerUnaryCall, sendUnaryData, status } from "@grpc/grpc-js"; 2 | import { 3 | Customer, 4 | GetCustomerRequest, 5 | makeUnaryRequest, 6 | startServer, 7 | } from "./utils"; 8 | import { MockServiceError } from "./MockServiceError"; 9 | 10 | describe("retryOptions", () => { 11 | let RESPONSE_DELAY: number; 12 | let THROW_ERROR: MockServiceError | undefined; 13 | let TOGGLE_THROWN_ERROR: boolean; 14 | 15 | beforeEach(async () => { 16 | THROW_ERROR = undefined; 17 | TOGGLE_THROWN_ERROR = false; 18 | RESPONSE_DELAY = 0; 19 | 20 | await startServer({ 21 | GetCustomer: ( 22 | call: ServerUnaryCall, 23 | callback: sendUnaryData 24 | ) => { 25 | if (THROW_ERROR) { 26 | callback(THROW_ERROR); 27 | if (TOGGLE_THROWN_ERROR) { 28 | THROW_ERROR = undefined; 29 | } 30 | } else { 31 | const timerid = setTimeout( 32 | () => callback(null, { id: "github", name: "Github" }), 33 | RESPONSE_DELAY 34 | ); 35 | call.on("cancelled", () => { 36 | if (timerid) { 37 | clearTimeout(timerid); 38 | } 39 | }); 40 | } 41 | }, 42 | }); 43 | }); 44 | 45 | test("should propagate timeout errors after all retries are exhausted", async () => { 46 | RESPONSE_DELAY = 2000; 47 | const request = await makeUnaryRequest( 48 | { id: "github" }, 49 | { timeout: 100, retryOptions: { retryCount: 3 } } 50 | ); 51 | 52 | expect(request.error?.message).toEqual( 53 | `4 DEADLINE_EXCEEDED: Deadline exceeded` 54 | ); 55 | expect(request.responseErrors).toEqual([ 56 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 57 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 58 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 59 | expect.objectContaining({ code: status.DEADLINE_EXCEEDED }), 60 | ]); 61 | expect(request.error).toStrictEqual(request.responseErrors[2]); 62 | }); 63 | 64 | test("should ignore previous service error if next request is successful", async () => { 65 | THROW_ERROR = new MockServiceError(status.INTERNAL); 66 | TOGGLE_THROWN_ERROR = true; 67 | const request = await makeUnaryRequest( 68 | { id: "github" }, 69 | { timeout: 500, retryOptions: { retryCount: 3 } } 70 | ); 71 | expect(request.result).toEqual({ 72 | id: "github", 73 | name: "Github", 74 | }); 75 | expect(request.responseErrors).toEqual([ 76 | expect.objectContaining({ code: status.INTERNAL }), 77 | ]); 78 | expect(request.error).toBeUndefined(); 79 | }); 80 | 81 | test("should successfully retry only on errors specified", async () => { 82 | TOGGLE_THROWN_ERROR = true; 83 | 84 | THROW_ERROR = new MockServiceError(status.INTERNAL); 85 | const request1 = await makeUnaryRequest( 86 | { id: "github" }, 87 | { retryOptions: { retryCount: 3, status: [status.INTERNAL] } } 88 | ); 89 | expect(request1.result).toEqual({ 90 | id: "github", 91 | name: "Github", 92 | }); 93 | expect(request1.responseErrors).toEqual([ 94 | expect.objectContaining({ code: status.INTERNAL }), 95 | ]); 96 | 97 | THROW_ERROR = new MockServiceError(status.NOT_FOUND, `Generic Not Found`); 98 | const { error } = await makeUnaryRequest( 99 | { id: "github" }, 100 | { retryOptions: { retryCount: 3, status: [status.INTERNAL] } } 101 | ); 102 | expect(error?.message).toEqual(`5 NOT_FOUND: Generic Not Found`); 103 | }); 104 | 105 | test("should support a single entry as the only retryable status", async () => { 106 | THROW_ERROR = new MockServiceError(status.INTERNAL); 107 | TOGGLE_THROWN_ERROR = true; 108 | const request = await makeUnaryRequest( 109 | { id: "github" }, 110 | { timeout: 500, retryOptions: { retryCount: 3, status: status.INTERNAL } } 111 | ); 112 | expect(request.result).toEqual({ 113 | id: "github", 114 | name: "Github", 115 | }); 116 | expect(request.responseErrors).toEqual([ 117 | expect.objectContaining({ code: status.INTERNAL }), 118 | ]); 119 | expect(request.error).toBeUndefined(); 120 | }); 121 | 122 | test("should propagate the last service error after all retries are exhausted", async () => { 123 | THROW_ERROR = new MockServiceError( 124 | status.INTERNAL, 125 | `Generic Service Error` 126 | ); 127 | const request = await makeUnaryRequest( 128 | { id: "github" }, 129 | { timeout: 500, retryOptions: { retryCount: 3 } } 130 | ); 131 | expect(request.error?.message).toEqual( 132 | `13 INTERNAL: Generic Service Error` 133 | ); 134 | expect(request.responseErrors).toEqual([ 135 | expect.objectContaining({ code: status.INTERNAL }), 136 | expect.objectContaining({ code: status.INTERNAL }), 137 | expect.objectContaining({ code: status.INTERNAL }), 138 | expect.objectContaining({ code: status.INTERNAL }), 139 | ]); 140 | expect(request.error).toStrictEqual(request.responseErrors[2]); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | jest.useFakeTimers(); 3 | }); 4 | 5 | afterEach(() => { 6 | jest.resetAllMocks(); 7 | jest.restoreAllMocks(); 8 | jest.clearAllTimers(); 9 | }); 10 | -------------------------------------------------------------------------------- /test/util.test.ts: -------------------------------------------------------------------------------- 1 | import { ProtoClient, ProtoRequest, RequestMethodType } from "../src"; 2 | import { UntypedProtoRequest } from "../src/untyped"; 3 | import { generateEndpointMatcher, normalizeRetryOptions } from "../src/util"; 4 | import { PROTO_FILE_PATHS } from "./utils"; 5 | 6 | describe("util", () => { 7 | let request: UntypedProtoRequest; 8 | 9 | beforeEach(() => { 10 | const client = new ProtoClient({ 11 | clientSettings: { 12 | endpoint: { 13 | address: `0.0.0.0:8081`, 14 | }, 15 | rejectOnAbort: true, 16 | }, 17 | protoSettings: { 18 | files: PROTO_FILE_PATHS, 19 | }, 20 | }); 21 | 22 | request = new ProtoRequest( 23 | { 24 | method: "customers.Customers.GetCustomer", 25 | requestMethodType: RequestMethodType.UnaryRequest, 26 | }, 27 | client 28 | ); 29 | }); 30 | 31 | describe("generateEndpointMatcher", () => { 32 | test("should match everything if match is undefined", () => { 33 | const matcher = generateEndpointMatcher(); 34 | expect(matcher).toBeInstanceOf(Function); 35 | expect(matcher(request.method, request)).toStrictEqual(true); 36 | }); 37 | 38 | test("should match string paths exactly", () => { 39 | const matcher = generateEndpointMatcher(`foo.bar.baz`); 40 | expect(matcher).toBeInstanceOf(Function); 41 | expect(matcher("foo.bar.baz", request)).toStrictEqual(true); 42 | expect(matcher("foo.foo.bar", request)).toStrictEqual(false); 43 | }); 44 | 45 | test("should match service or namespace when defined", () => { 46 | let matcher = generateEndpointMatcher(`foo.bar.*`); 47 | expect(matcher).toBeInstanceOf(Function); 48 | expect(matcher("foo.bar.baz", request)).toStrictEqual(true); 49 | expect(matcher("foo.bar.bar", request)).toStrictEqual(true); 50 | expect(matcher("foo.baz.foo", request)).toStrictEqual(false); 51 | 52 | matcher = generateEndpointMatcher(`foo.*`); 53 | expect(matcher("foo.bar.baz", request)).toStrictEqual(true); 54 | expect(matcher("foo.bar.bar", request)).toStrictEqual(true); 55 | expect(matcher("foo.baz.foo", request)).toStrictEqual(true); 56 | expect(matcher("bar.foo.baz", request)).toStrictEqual(false); 57 | }); 58 | 59 | test("should use regex matching when defined", () => { 60 | const matcher = generateEndpointMatcher(/^foo\.bar/); 61 | expect(matcher).toBeInstanceOf(Function); 62 | expect(matcher("foo.bar.baz", request)).toStrictEqual(true); 63 | expect(matcher("foo.baz.bar", request)).toStrictEqual(false); 64 | }); 65 | 66 | test("should use filter matching when defined", () => { 67 | const matcher = generateEndpointMatcher( 68 | (method: string) => method === "foo.bar.baz" 69 | ); 70 | expect(matcher).toBeInstanceOf(Function); 71 | expect(matcher("foo.bar.baz", request)).toStrictEqual(true); 72 | expect(matcher("foo.baz.bar", request)).toStrictEqual(false); 73 | }); 74 | }); 75 | 76 | describe("normalizeRetryOptions", () => { 77 | test("should handle the various permutations to retryOptions", () => { 78 | expect(normalizeRetryOptions(undefined)).toStrictEqual(undefined); 79 | expect(normalizeRetryOptions(true)).toStrictEqual({ retryCount: 1 }); 80 | expect(normalizeRetryOptions(false)).toStrictEqual({ retryCount: 0 }); 81 | expect(normalizeRetryOptions(15)).toStrictEqual({ retryCount: 15 }); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { loadSync } from "@grpc/proto-loader"; 2 | import { 3 | loadPackageDefinition, 4 | Server, 5 | ServerCredentials, 6 | UntypedServiceImplementation, 7 | } from "@grpc/grpc-js"; 8 | import { 9 | ProtoClient, 10 | RequestOptions, 11 | StreamReader, 12 | StreamWriterSandbox, 13 | } from "../src"; 14 | import { promisify } from "util"; 15 | import { EventEmitter, Readable } from "stream"; 16 | 17 | let activeServers: Server[] = []; 18 | let activeClient: ProtoClient | undefined; 19 | 20 | export interface Customer { 21 | id?: string; 22 | name?: string; 23 | } 24 | 25 | export interface GetCustomerRequest { 26 | id?: string; 27 | } 28 | 29 | export interface FindCustomersRequest { 30 | name?: string; 31 | } 32 | 33 | export interface CustomersResponse { 34 | customers?: Customer[]; 35 | } 36 | 37 | export const PROTO_FILE_PATHS: string[] = [ 38 | `${__dirname}/protos/customers.proto`, 39 | `${__dirname}/protos/products.proto`, 40 | ]; 41 | 42 | export const wait = (time?: number) => 43 | new Promise((resolve) => setTimeout(resolve, time || 5)); 44 | 45 | export const getClient = () => { 46 | if (!activeClient) { 47 | throw new Error(`Need to run startServer before getting active client`); 48 | } 49 | 50 | return activeClient; 51 | }; 52 | 53 | export const makeUnaryRequest = ( 54 | data?: GetCustomerRequest, 55 | requestOptions?: RequestOptions | AbortController 56 | ) => 57 | getClient().makeUnaryRequest( 58 | "customers.Customers.GetCustomer", 59 | data as GetCustomerRequest, 60 | requestOptions as RequestOptions 61 | ); 62 | 63 | export const makeClientStreamRequest = ( 64 | writerSandbox?: 65 | | StreamWriterSandbox 66 | | Readable 67 | | EventEmitter, 68 | requestOptions?: RequestOptions | AbortController 69 | ) => 70 | getClient().makeClientStreamRequest( 71 | "customers.Customers.EditCustomer", 72 | writerSandbox as StreamWriterSandbox, 73 | requestOptions as RequestOptions 74 | ); 75 | 76 | export const makeServerStreamRequest = ( 77 | data: FindCustomersRequest | StreamReader, 78 | streamReader?: StreamReader, 79 | requestOptions?: RequestOptions | AbortController 80 | ) => 81 | getClient().makeServerStreamRequest( 82 | "customers.Customers.FindCustomers", 83 | data as FindCustomersRequest, 84 | streamReader as StreamReader, 85 | requestOptions as RequestOptions 86 | ); 87 | 88 | export const makeBidiStreamRequest = ( 89 | writerSandbox: 90 | | StreamWriterSandbox 91 | | Readable 92 | | EventEmitter, 93 | streamReader: StreamReader, 94 | requestOptions?: RequestOptions | AbortController 95 | ) => 96 | getClient().makeBidiStreamRequest( 97 | "customers.Customers.CreateCustomers", 98 | writerSandbox as StreamWriterSandbox, 99 | streamReader, 100 | requestOptions as RequestOptions 101 | ); 102 | 103 | export const getUnaryRequest = ( 104 | data?: GetCustomerRequest, 105 | requestOptions?: RequestOptions | AbortController 106 | ) => 107 | getClient().getUnaryRequest( 108 | "customers.Customers.GetCustomer", 109 | data as GetCustomerRequest, 110 | requestOptions as RequestOptions 111 | ); 112 | 113 | export const getClientStreamRequest = ( 114 | writerSandbox?: 115 | | StreamWriterSandbox 116 | | Readable 117 | | EventEmitter, 118 | requestOptions?: RequestOptions | AbortController 119 | ) => 120 | getClient().getClientStreamRequest( 121 | "customers.Customers.EditCustomer", 122 | writerSandbox as StreamWriterSandbox, 123 | requestOptions as RequestOptions 124 | ); 125 | 126 | export const getServerStreamRequest = ( 127 | data?: FindCustomersRequest | StreamReader, 128 | streamReader?: StreamReader, 129 | requestOptions?: RequestOptions | AbortController 130 | ) => 131 | getClient().getServerStreamRequest( 132 | "customers.Customers.FindCustomers", 133 | data as FindCustomersRequest, 134 | streamReader as StreamReader, 135 | requestOptions as RequestOptions 136 | ); 137 | 138 | export const getBidiStreamRequest = ( 139 | writerSandbox: 140 | | StreamWriterSandbox 141 | | Readable 142 | | EventEmitter, 143 | streamReader: StreamReader, 144 | requestOptions?: RequestOptions | AbortController 145 | ) => 146 | getClient().getBidiStreamRequest( 147 | "customers.Customers.CreateCustomers", 148 | writerSandbox as StreamWriterSandbox, 149 | streamReader, 150 | requestOptions as RequestOptions 151 | ); 152 | 153 | export const generateProtoServer = async ( 154 | serviceImpl: UntypedServiceImplementation 155 | ) => { 156 | const packageDefinition = loadSync(PROTO_FILE_PATHS, { 157 | keepCase: true, 158 | longs: String, 159 | enums: String, 160 | defaults: true, 161 | oneofs: true, 162 | }); 163 | const customersProto = loadPackageDefinition(packageDefinition).customers; 164 | 165 | const server = new Server(); 166 | activeServers.push(server); 167 | 168 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 169 | server.addService((customersProto as any).Customers.service, serviceImpl); 170 | 171 | const port = await new Promise((resolve, reject) => { 172 | server.bindAsync( 173 | "0.0.0.0:0", 174 | ServerCredentials.createInsecure(), 175 | (e, port) => { 176 | if (e) { 177 | reject(e); 178 | } else { 179 | server.start(); 180 | resolve(port); 181 | } 182 | } 183 | ); 184 | }); 185 | 186 | return { server, port }; 187 | }; 188 | 189 | export const startServer = async ( 190 | serviceImpl: UntypedServiceImplementation 191 | ) => { 192 | jest.useRealTimers(); 193 | 194 | const results = await generateProtoServer(serviceImpl); 195 | 196 | activeClient = new ProtoClient({ 197 | clientSettings: { 198 | endpoint: `0.0.0.0:${results.port}`, 199 | }, 200 | protoSettings: { 201 | files: PROTO_FILE_PATHS, 202 | }, 203 | }); 204 | 205 | return { client: activeClient, ...results }; 206 | }; 207 | 208 | afterEach(async () => { 209 | activeClient?.close(); 210 | for (const server of activeServers) { 211 | await promisify(server.tryShutdown.bind(server))(); 212 | } 213 | 214 | activeClient = undefined; 215 | activeServers = []; 216 | }); 217 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNEXT", 4 | "module": "NodeNext", 5 | "outDir": "dist/", 6 | "alwaysStrict": true, 7 | "diagnostics": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "declaration": true, 12 | "declarationMap": true, 13 | "sourceMap": true, 14 | "noUnusedLocals": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "forceConsistentCasingInFileNames": true 19 | }, 20 | "include": ["src"], 21 | "exclude": ["test/client"] 22 | } 23 | --------------------------------------------------------------------------------