├── .editorconfig ├── .gitignore ├── .travis.yml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src ├── envoy-context-store.ts ├── envoy-context.ts ├── envoy-fetch.ts ├── envoy-grpc-request-params.ts ├── envoy-http-client.ts ├── envoy-http-request-params.ts ├── envoy-node.ts ├── envoy-proto-decorator.ts ├── envoy-request-params-refiner.ts ├── envoy-request-params.ts └── types.ts ├── test ├── envoy-context-env.test.ts ├── envoy-context-store.test.ts ├── envoy-context.test.ts ├── envoy-fetch.test.ts ├── envoy-grpc-request-params.test.ts ├── envoy-http-client.test.ts ├── envoy-request-params-refiner.test.ts ├── grpc-bidi-stream.test.ts ├── grpc-client-stream.test.ts ├── grpc-server-stream.test.ts ├── grpc.test.ts ├── http.test.ts └── lib │ ├── common-test-server.ts │ ├── envoy-grpc-config.yaml │ ├── envoy-http-config.yaml │ ├── grpc-test-server.ts │ ├── http-test-server.ts │ ├── ping.proto │ ├── simple-post.ts │ ├── utils.ts │ └── zipkin-mock.ts ├── tools ├── gh-pages-publish.ts └── semantic-release-prepare.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .idea 7 | dist 8 | compiled 9 | .awcache 10 | /bin/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | yarn: true 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - "10" 10 | install: 11 | - npm install 12 | - npm run download-envoy 13 | script: 14 | - export PATH=./node_modules/.bin/:$PATH 15 | - npm run test:prod && npm run build 16 | after_success: 17 | - npm run report-coverage 18 | - if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then npm run deploy-docs; fi 19 | - npm run semantic-release 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest Tests", 11 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 12 | "sourceMaps": true, 13 | "args": [ 14 | "-i", 15 | "--runInBand", // https://facebook.github.io/jest/docs/en/cli.html#runinband - don't parallelize 16 | "--no-cache", // https://facebook.github.io/jest/docs/en/cli.html#cache - just avoid caching issues 17 | // "-t", 18 | // "should propagate the tracing header directly in direct mode" 19 | ], 20 | // "preLaunchTask": "build", 21 | "internalConsoleOptions": "openOnSessionStart", 22 | "outFiles": [ 23 | "${workspaceRoot}/dist/**/*" 24 | ] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Tubi inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Envoy Node 2 | 3 | [![Travis](https://api.travis-ci.org/Tubitv/envoy-node.svg?branch=master)](https://travis-ci.org/Tubitv/envoy-node) [![Coverage Status](https://coveralls.io/repos/github/Tubitv/envoy-node/badge.svg?branch=master)](https://coveralls.io/github/Tubitv/envoy-node?branch=master) [![npm version](https://img.shields.io/npm/v/envoy-node.svg)](https://www.npmjs.com/package/envoy-node) ![npm license](https://img.shields.io/npm/l/envoy-node.svg) 4 | 5 | This is a boilerplate to help you adopt [Envoy](https://github.com/envoyproxy/envoy). 6 | 7 | There are multiple ways to config Envoy, one of the convenience way to mange different egress traffic is route the traffic by hostname (using [virtual hosts](https://www.envoyproxy.io/docs/envoy/v1.13.1/api-v2/rds.proto.html#virtualhost)). By doing so, you can use one egress port for all your egress dependencies: 8 | 9 | ```yaml 10 | static_resources: 11 | listeners: 12 | - name: egress_listener 13 | address: 14 | socket_address: 15 | address: 0.0.0.0 16 | port_value: 12345 17 | filter_chains: 18 | - filters: 19 | - name: envoy.http_connection_manager 20 | typed_config: 21 | "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager 22 | codec_type: AUTO 23 | stat_prefix: ingress 24 | use_remote_address: true 25 | stat_prefix: http.test.egress 26 | route_config: 27 | name: egress_route_config 28 | virtual_hosts: 29 | - name: foo_service 30 | domains: 31 | - foo.service:8888 # Do not miss the port number here 32 | routes: 33 | - match: 34 | prefix: / 35 | route: 36 | cluster: remote_foo_server 37 | - name: bar_service 38 | domains: 39 | - bar.service:8888 # Do not miss the port number here 40 | routes: 41 | - match: 42 | prefix: / 43 | route: 44 | cluster: remote_bar_server 45 | http_filters: 46 | - name: envoy.router 47 | typed_config: 48 | "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router 49 | dynamic_stats: true 50 | ``` 51 | 52 | But it will bring you new problem, your code is becoming verbose: 53 | 54 | 1. routing traffic to `127.0.0.1:12345` where egress port is listening 55 | 2. setting host headers for each request 56 | 3. propagating the tracing information 57 | 58 | And this library is going to help you deal with these things elegantly. 59 | 60 | First, let's tell the library where the egress port is binding. A recommended way is to set the information on the ingress header by [request_headers_to_add](https://www.envoyproxy.io/docs/envoy/v1.5.0/api-v2/rds.proto.html#envoy-api-field-routeconfiguration-request-headers-to-add): 61 | 62 | ```yaml 63 | request_headers_to_add: 64 | - header: 65 | key: x-tubi-envoy-egress-port 66 | value: "12345" 67 | - header: 68 | key: x-tubi-envoy-egress-addr 69 | value: 127.0.0.1 70 | ``` 71 | 72 | You can also set this by the constructor parameters of `EnvoyContext`. 73 | 74 | ## High level APIs 75 | 76 | ### HTTP 77 | 78 | For HTTP, you can new the client like this: 79 | 80 | ```js 81 | const { EnvoyHttpClient, HttpRetryOn } = require("envoy-node"); 82 | 83 | async function awesomeAPI(req, res) { 84 | const client = new EnvoyHttpClient(req.headers); 85 | const url = `http://foo.service:10080/path/to/rpc` 86 | const request = { 87 | message: "ping", 88 | }; 89 | const optionalParams = { 90 | // timeout 1 second 91 | timeout: 1000, 92 | // envoy will retry if server return HTTP 409 (for now) 93 | retryOn: [HttpRetryOn.RETRIABLE_4XX], 94 | // retry 3 times at most 95 | maxRetries: 3, 96 | // each retry will timeout in 300 ms 97 | perTryTimeout: 300, 98 | // any other headers you want to set 99 | headers: { 100 | "x-extra-header-you-want": "value", 101 | }, 102 | }; 103 | const serializedJsonResponse = await client.post(url, request, optionalParams); 104 | res.send({ serializedJsonResponse }); 105 | res.end(); 106 | } 107 | ``` 108 | 109 | ### gRPC 110 | 111 | For gRPC, you can new the client like this: 112 | 113 | #### General RPC 114 | 115 | ```js 116 | const grpc = require("@grpc/grpc-js"); 117 | const { envoyProtoDecorator, GrpcRetryOn } = require("envoy-node"); 118 | 119 | const PROTO_PATH = __dirname + "/ping.proto"; 120 | const Ping = grpc.load(PROTO_PATH).test.Ping; 121 | 122 | // the original client will be decorated as a new class 123 | const PingClient = envoyProtoDecorator(Ping); 124 | 125 | async function awesomeAPI(call, callback) { 126 | const client = new PingClient("bar.service:10081", call.metadata); 127 | const request = { 128 | message: "ping", 129 | }; 130 | const optionalParams = { 131 | // timeout 1 second 132 | timeout: 1000, 133 | // envoy will retry if server return DEADLINE_EXCEEDED 134 | retryOn: [GrpcRetryOn.DEADLINE_EXCEEDED], 135 | // retry 3 times at most 136 | maxRetries: 3, 137 | // each retry will timeout in 300 ms 138 | perTryTimeout: 300, 139 | // any other headers you want to set 140 | headers: { 141 | "x-extra-header-you-want": "value", 142 | }, 143 | }; 144 | const response = await client.pathToRpc(request, optionalParams); 145 | callback(undefined, { remoteResponse: response }); 146 | } 147 | ``` 148 | 149 | #### Streaming API 150 | 151 | But they are also decorated to send the Envoy context. You can also specify the optional params (the last one) for features like `timeout` / `retryOn` / `maxRetries` / `perTryTimeout` provided by Envoy. 152 | 153 | **NOTE**: 154 | 155 | 1. For streaming API, they are not implemented as `async` signature. 156 | 2. The optional params (`timeout` etc.) is not tested and Envoy is not documented how it deal with streaming. 157 | 158 | ##### Client streaming 159 | 160 | ```js 161 | const stream = innerClient.clientStream((err, response) => { 162 | if (err) { 163 | // error handling 164 | return; 165 | } 166 | console.log("server responses:", response); 167 | }); 168 | stream.write({ message: "ping" }); 169 | stream.write({ message: "ping again" }); 170 | stream.end(); 171 | ``` 172 | 173 | ##### Sever streaming 174 | 175 | ```js 176 | const stream = innerClient.serverStream({ message: "ping" }); 177 | stream.on("error", error => { 178 | // handle error here 179 | }); 180 | stream.on("data", (data: any) => { 181 | console.log("server sent:", data); 182 | }); 183 | stream.on("end", () => { 184 | // ended 185 | }); 186 | ``` 187 | 188 | ##### Bidirectional streaming 189 | 190 | ```js 191 | const stream = innerClient.bidiStream(); 192 | stream.write({ message: "ping" }); 193 | stream.write({ message: "ping again" }); 194 | stream.on("error", error => { 195 | // handle error here 196 | }); 197 | stream.on("data", (data: any) => { 198 | console.log("sever sent:", data); 199 | }); 200 | stream.on("end", () => { 201 | stream.end(); 202 | }); 203 | stream.end(); 204 | ``` 205 | 206 | ## Low level APIs 207 | 208 | If you want to have more control of your code, you can also use the low level APIs of this library: 209 | 210 | ```js 211 | const { envoyFetch, EnvoyContext, EnvoyHttpRequestParams, EnvoyGrpcRequestParams, envoyRequestParamsRefiner } = require("envoy-node"); 212 | 213 | // ... 214 | 215 | const context = new EnvoyContext( 216 | headerOrMetadata, 217 | // specify port if we cannot indicate from 218 | // - `x-tubi-envoy-egress-port` header or 219 | // - environment variable ENVOY_DEFAULT_EGRESS_PORT 220 | envoyEgressPort, 221 | // specify address if we cannot indicate from 222 | // - `x-tubi-envoy-egress-addr` header or 223 | // - environment variable ENVOY_DEFAULT_EGRESS_ADDR 224 | envoyEgressAddr 225 | ); 226 | 227 | // for HTTP 228 | const params = new EnvoyHttpRequestParams(context, optionalParams); 229 | envoyFetch(params, url, init /* init like original node-fetch */) 230 | .then(res => { 231 | console.log("envoy tells:", res.overloaded, res.upstreamServiceTime); 232 | return res.json(); // or res.text(), just use it as what node-fetch returned 233 | }) 234 | .then(/* ... */) 235 | 236 | // you are using request? 237 | const yourOldRequestParams = {}; /* url or options */ 238 | request(envoyRequestParamsRefiner(yourOldRequestParams, context /* or headers, grpc.Metadata */ )) 239 | 240 | // for gRPC 241 | const client = new Ping(( 242 | `${context.envoyEgressAddr}:${context.envoyEgressPort}`, // envoy egress port 243 | grpc.credentials.createInsecure() 244 | ); 245 | const requestMetadata = params.assembleRequestMeta() 246 | client.pathToRpc( 247 | request, 248 | requestMetadata, 249 | { 250 | host: "bar.service:10081" 251 | }, 252 | (error, response) => { 253 | // ... 254 | }) 255 | 256 | ``` 257 | 258 | Check out the [detail document](https://tubitv.github.io/envoy-node/) if needed. 259 | 260 | ## Context store 261 | 262 | Are you finding it's too painful for you to propagate the context information through function calls' parameter? 263 | 264 | If you are using Node.js V8, here is a solution for you: 265 | 266 | ```javascript 267 | import { envoyContextStore } from "envoy-node"; // import the store 268 | 269 | envoyContextStore.enable(); // put this code when you application init 270 | 271 | // for each request, call this: 272 | envoyContextStore.set(new EnvoyContext(req.headers)); 273 | 274 | // for later get the request, simply: 275 | envoyContextStore.get(); 276 | ``` 277 | 278 | **IMPORTANT** 279 | 280 | 1. according to the implementation, it's strictly requiring the `set` method is called exactly once per request. Or you will get incorrect context. Please check the document for more details. (TBD: We are working on a blog post for the details.) 281 | 2. according to `asyn_hooks` implementation, [`destroy` is not called if the code is using HTTP keep alive](https://github.com/nodejs/node/issues/19859). Please use `setEliminateInterval` to set a time for deleting old context data or you may have memory leak. The default (5 mintues) is using if you don't set it. 282 | 283 | 284 | ## For dev and test, or migrating to Envoy 285 | 286 | If you are developing the application, you may probably do not have Envoy running. You may want to call the service directly: 287 | 288 | Either: 289 | 290 | ```js 291 | new EnvoyContext({ 292 | meta: grpcMetadata_Or_HttpHeader, 293 | 294 | /** 295 | * For dev or test environment, we usually don't have Envoy running. By setting directMode = true 296 | * will make all the traffic being sent directly. 297 | * If you set directMode to true, envoyManagedHosts will be ignored and set to an empty set. 298 | */ 299 | directMode: true, 300 | 301 | /** 302 | * For easier migrate service to envoy step by step, we can route traffic to envoy for those service 303 | * migrated. Fill this set for the migrated service. 304 | * This field is default to `undefined` which means all traffic will be route to envoy. 305 | * If this field is set to `undefined`, this library will also try to read it from `x-tubi-envoy-managed-host`. 306 | * You can set in envoy config, like this: 307 | * 308 | * ``yaml 309 | * request_headers_to_add: 310 | * - key: x-tubi-envoy-managed-host 311 | * value: hostname:12345 312 | * - key: x-tubi-envoy-managed-host 313 | * value: foo.bar:8080 314 | * `` 315 | * 316 | * If you set this to be an empty set, then no traffic will be route to envoy. 317 | */ 318 | envoyManagedHosts: new Set(["some-hostname:8080"]); 319 | 320 | }) 321 | ``` 322 | 323 | or: 324 | 325 | ```shell 326 | export ENVOY_DIRECT_MODE=true # 1 works as well 327 | ``` 328 | 329 | ## Contributing 330 | 331 | For developing or running test of this library, you probably need to: 332 | 333 | 1. have an envoy binary in your `PATH`, or: 334 | ```shell 335 | $ npm run download-envoy 336 | $ export PATH=./node_modules/.bin/:$PATH 337 | ``` 338 | 2. to commit your code change: 339 | ```shell 340 | $ git add . # or the things you want to commit 341 | $ npm run commit # and answer the commit message accordingly 342 | ``` 343 | 3. for each commit, the CI will auto release base on commit messages, to allow keeping the version align with Envoy, let's use fix instead of feature unless we want to upgrade minor version. 344 | 345 | ## License 346 | 347 | MIT 348 | 349 | ## Credits 350 | 351 | - this library is init by alexjoverm's [typescript-library-starter](https://github.com/alexjoverm/typescript-library-starter) 352 | 353 | - Thanks [@mattklein123](https://github.com/mattklein123) and Envoy community for questions and answers. 354 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "envoy-node", 3 | "version": "2.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "dist/envoy-node.umd.js", 7 | "module": "dist/envoy-node.es5.js", 8 | "typings": "dist/types/envoy-node.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "author": "Yingyu Cheng ", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/Tubitv/envoy-node.git" 16 | }, 17 | "license": "MIT", 18 | "engines": { 19 | "node": ">=6.0.0" 20 | }, 21 | "scripts": { 22 | "download-envoy": "if [ ! -f ./node_modules/.bin/envoy ]; then wget https://s3-us-west-1.amazonaws.com/tubi-public-binaries/envoy/v1.13.1/envoy -O node_modules/.bin/envoy && chmod +x node_modules/.bin/envoy; fi", 23 | "lint": "tslint -t codeFrame 'src/**/*.ts' 'test/**/*.ts' 'types/**/*.ts'", 24 | "prebuild": "rimraf dist", 25 | "build": "tsc && tsc --module commonjs --outDir dist/lib && rollup -c rollup.config.ts && typedoc --out dist/docs --target es6 --theme minimal --mode file src", 26 | "start": "tsc -w & rollup -c rollup.config.ts -w", 27 | "test": "jest --runInBand", 28 | "test:watch": "jest --watch --runInBand", 29 | "test:prod": "npm run lint && npm run test -- --coverage --no-cache", 30 | "deploy-docs": "ts-node tools/gh-pages-publish", 31 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 32 | "commit": "git-cz", 33 | "semantic-release": "semantic-release", 34 | "semantic-release-prepare": "ts-node tools/semantic-release-prepare" 35 | }, 36 | "lint-staged": { 37 | "{src,test}/**/*.ts": [ 38 | "prettier --write", 39 | "git add" 40 | ] 41 | }, 42 | "config": { 43 | "commitizen": { 44 | "path": "node_modules/cz-conventional-changelog" 45 | }, 46 | "validate-commit-msg": { 47 | "types": "conventional-commit-types", 48 | "helpMessage": "Use \"npm run commit\" instead, we use conventional-changelog format :) (https://github.com/commitizen/cz-cli)" 49 | } 50 | }, 51 | "jest": { 52 | "transform": { 53 | ".ts": "/node_modules/ts-jest/preprocessor.js" 54 | }, 55 | "testRegex": "test/[\\w-\\.]+\\.test\\.ts", 56 | "moduleFileExtensions": [ 57 | "ts", 58 | "tsx", 59 | "js" 60 | ], 61 | "coveragePathIgnorePatterns": [ 62 | "/node_modules/", 63 | "/test/" 64 | ], 65 | "coverageThreshold": { 66 | "global": { 67 | "branches": 90, 68 | "functions": 90, 69 | "lines": 95, 70 | "statements": 95 71 | } 72 | }, 73 | "collectCoverage": true, 74 | "testEnvironment": "node" 75 | }, 76 | "devDependencies": { 77 | "@grpc/proto-loader": "^0.5.4", 78 | "@types/jest": "^25.2.3", 79 | "@types/node": "^14.0.9", 80 | "@types/node-fetch": "^2.5.7", 81 | "@types/protobufjs": "^6.0.0", 82 | "@types/request": "^2.48.5", 83 | "colors": "^1.4.0", 84 | "commitizen": "^4.1.2", 85 | "coveralls": "^3.1.0", 86 | "cross-env": "^7.0.2", 87 | "cz-conventional-changelog": "^3.2.0", 88 | "husky": "^4.2.5", 89 | "jest": "^26.0.1", 90 | "lint-staged": "^10.2.7", 91 | "lodash.camelcase": "^4.3.0", 92 | "prettier": "^2.0.5", 93 | "prompt": "^1.0.0", 94 | "replace-in-file": "^6.0.0", 95 | "rimraf": "^3.0.2", 96 | "rollup": "^2.12.1", 97 | "rollup-plugin-commonjs": "^10.1.0", 98 | "rollup-plugin-json": "^4.0.0", 99 | "rollup-plugin-node-resolve": "^5.2.0", 100 | "rollup-plugin-sourcemaps": "^0.6.2", 101 | "semantic-release": "^17.0.8", 102 | "ts-jest": "^26.1.0", 103 | "ts-node": "^8.10.2", 104 | "tslint": "^6.1.2", 105 | "tslint-config-prettier": "^1.18.0", 106 | "tslint-config-standard": "^9.0.0", 107 | "typedoc": "^0.17.7", 108 | "typedoc-plugin-internal-external": "^2.1.1", 109 | "typescript": "^3.9.3", 110 | "validate-commit-msg": "^2.14.0" 111 | }, 112 | "dependencies": { 113 | "async_hooks": "^1.0.0", 114 | "@grpc/grpc-js": "^1.8.0", 115 | "node-fetch": "^2.6.0", 116 | "request": "^2.88.2" 117 | }, 118 | "husky": { 119 | "hooks": { 120 | "commit-msg": "validate-commit-msg", 121 | "pre-commit": "lint-staged", 122 | "pre-push": "npm run test:prod && npm run build" 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import json from "rollup-plugin-json"; 4 | import sourceMaps from "rollup-plugin-sourcemaps"; 5 | import camelCase from "lodash.camelcase"; 6 | 7 | const pkg = require("./package.json"); 8 | 9 | const libraryName = "envoy-node"; 10 | 11 | export default { 12 | input: `dist/es/${libraryName}.js`, 13 | output: [ 14 | { file: pkg.main, name: camelCase(libraryName), format: "umd" }, 15 | { file: pkg.module, format: "es" }, 16 | ], 17 | sourcemap: true, 18 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 19 | external: [ 20 | "grpc", 21 | "protobufjs", 22 | "node-fetch", 23 | "url", 24 | "util" 25 | ], 26 | watch: { 27 | include: "dist/es/**", 28 | }, 29 | plugins: [ 30 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 31 | commonjs(), 32 | json(), 33 | // Allow node_modules resolution, so you can use 'external' to control 34 | // which external modules to include in the bundle 35 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 36 | resolve(), 37 | 38 | // Resolve source maps to the original source 39 | sourceMaps(), 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /src/envoy-context-store.ts: -------------------------------------------------------------------------------- 1 | import * as asyncHooks from "async_hooks"; 2 | import EnvoyContext from "./envoy-context"; 3 | 4 | /** 5 | * this will store the information of a node 6 | */ 7 | export class NodeInfo { 8 | /** 9 | * the reference count of this info, 10 | * init will be 1, 11 | * when this async execution trigger another execution, 12 | * this will increase 1, 13 | * when this execution or one of its child execution is destroyed, 14 | * this will decrease 1 15 | */ 16 | referenceCount = 1; 17 | 18 | /** 19 | * who trigger this async execution 20 | */ 21 | readonly triggerAsyncId: number; 22 | 23 | /** 24 | * the context set in this execution 25 | */ 26 | context?: EnvoyContext = undefined; 27 | 28 | constructor(triggerAsyncId: number) { 29 | this.triggerAsyncId = triggerAsyncId; 30 | } 31 | } 32 | 33 | /** 34 | * Eliminate Store 35 | * using two map for context storage, to avoid holding too many data 36 | * it will eliminate the old data 37 | */ 38 | export class EliminateStore { 39 | private old = new Map(); 40 | private current = new Map(); 41 | 42 | private lastEliminateTime = Date.now(); 43 | 44 | /** 45 | * get context 46 | * @param asyncId asyncId 47 | */ 48 | get(asyncId: number) { 49 | const infoFromCurrent = this.current.get(asyncId); 50 | if (infoFromCurrent !== undefined) { 51 | return infoFromCurrent; 52 | } 53 | const infoFromOld = this.old.get(asyncId); 54 | if (infoFromOld !== undefined) { 55 | this.current.set(asyncId, infoFromOld); 56 | } 57 | return infoFromOld; 58 | } 59 | 60 | /** 61 | * set context 62 | * @param asyncId asyncId 63 | * @param info context info 64 | */ 65 | set(asyncId: number, info: NodeInfo) { 66 | this.current.set(asyncId, info); 67 | } 68 | 69 | /** 70 | * delete context 71 | * @param asyncId asyncId 72 | */ 73 | delete(asyncId: number) { 74 | this.current.delete(asyncId); 75 | this.old.delete(asyncId); 76 | } 77 | 78 | /** 79 | * clear all data 80 | */ 81 | clear() { 82 | this.old.clear(); 83 | this.current.clear(); 84 | } 85 | 86 | /** 87 | * eliminate the old data 88 | */ 89 | eliminate() { 90 | this.old = this.current; 91 | this.current = new Map(); 92 | this.lastEliminateTime = Date.now(); 93 | } 94 | 95 | /** 96 | * get last eliminate time 97 | */ 98 | getLastEliminateTime() { 99 | return this.lastEliminateTime; 100 | } 101 | 102 | /** 103 | * the current size 104 | */ 105 | size() { 106 | return this.current.size; 107 | } 108 | 109 | /** 110 | * the old store size 111 | */ 112 | oldSize() { 113 | return this.old.size; 114 | } 115 | } 116 | 117 | const store = new EliminateStore(); 118 | let enabled = false; 119 | let eliminateInterval = 300 * 1000; // 300s, 5 mins 120 | let notDestroyedHooks = 0; 121 | 122 | /** 123 | * set the store's eliminate interval, context data older than this and not 124 | * read will be eventually eliminated 125 | * @param interval time in milliseconds 126 | */ 127 | function setEliminateInterval(interval: number) { 128 | eliminateInterval = interval; 129 | } 130 | 131 | /** 132 | * get eliminate interval 133 | */ 134 | function getEliminateInterval() { 135 | return eliminateInterval; 136 | } 137 | 138 | /** 139 | * clean up will decrease the reference count. 140 | * if the reference count is 0, it will remove it from the store and decrease its parent's reference count. 141 | * and try to see if its parent needs to be clean up as well. 142 | * @param asyncId the asyncId of the execution needs to be cleaned up 143 | */ 144 | function storeCleanUp(asyncId: number) { 145 | const info = store.get(asyncId); 146 | if (info === undefined) { 147 | return; 148 | } 149 | info.referenceCount--; 150 | if (info.referenceCount === 0) { 151 | store.delete(asyncId); 152 | storeCleanUp(info.triggerAsyncId); 153 | } 154 | } 155 | 156 | const asyncHook = asyncHooks.createHook({ 157 | init(asyncId, type, triggerAsyncId, resource) { 158 | notDestroyedHooks++; 159 | /* istanbul ignore next */ 160 | if (Date.now() - store.getLastEliminateTime() > eliminateInterval) { 161 | store.eliminate(); 162 | } 163 | let triggerInfo = store.get(triggerAsyncId); 164 | if (!triggerInfo) { 165 | triggerInfo = new NodeInfo(-1); 166 | store.set(triggerAsyncId, triggerInfo); 167 | } 168 | triggerInfo.referenceCount++; 169 | const info = new NodeInfo(triggerAsyncId); 170 | store.set(asyncId, info); 171 | }, 172 | destroy(asyncId) { 173 | notDestroyedHooks--; 174 | storeCleanUp(asyncId); 175 | }, 176 | }); 177 | 178 | /** 179 | * Enable the context store, 180 | * you should call this function as early as possible, 181 | * i.e. put it in your application's start. 182 | */ 183 | function enable() { 184 | if (!enabled) { 185 | asyncHook.enable(); 186 | enabled = true; 187 | } else { 188 | console.trace("[envoy-node] You want to enable the enabled store"); 189 | } 190 | } 191 | 192 | /** 193 | * Disable the context store, 194 | * all data will be clean up as well. 195 | * This function is not intended to be call in the application life cycle. 196 | */ 197 | function disable() { 198 | if (enabled) { 199 | asyncHook.disable(); 200 | store.clear(); 201 | enabled = false; 202 | } else { 203 | console.trace("[envoy-node] You want to disable the disabled store"); 204 | } 205 | } 206 | 207 | function markContext(triggerAsyncId: number, context: EnvoyContext) { 208 | const triggerInfo = store.get(triggerAsyncId); 209 | if (triggerInfo === undefined) { 210 | // no trigger info 211 | return; // skip 212 | } 213 | if (triggerInfo.context) { 214 | // trigger id has context already (reach to the border of the other request) 215 | return; // done 216 | } 217 | triggerInfo.context = context; 218 | markContext(triggerInfo.triggerAsyncId, context); 219 | } 220 | 221 | /** 222 | * According to the context store design, this function is required to be called exactly once for a request. 223 | * Setting multiple calls to this function will lead to context corruption. 224 | * @param context the context you want to set 225 | */ 226 | function set(context: EnvoyContext) { 227 | if (!enabled) { 228 | console.trace("[envoy-node] cannot set context when store is not enabled."); 229 | return; 230 | } 231 | const asyncId = asyncHooks.executionAsyncId(); 232 | const info = store.get(asyncId); 233 | /* istanbul ignore next */ 234 | if (info === undefined) { 235 | console.trace( 236 | "[envoy-node] Cannot find info of current execution, have you enabled the context store correctly?" 237 | ); 238 | return; 239 | } 240 | info.context = context; 241 | markContext(info.triggerAsyncId, context); 242 | } 243 | 244 | /** 245 | * get context from the execution tree 246 | * @param asyncId the async id 247 | */ 248 | function getContext(asyncId: number): EnvoyContext | undefined { 249 | const info = store.get(asyncId); 250 | /* istanbul ignore next */ 251 | if (!info) { 252 | return undefined; 253 | } 254 | if (!info.context) { 255 | info.context = getContext(info.triggerAsyncId); 256 | } 257 | return info.context; 258 | } 259 | 260 | /** 261 | * get the context previous set in the store of the current execution 262 | */ 263 | function get(): EnvoyContext | undefined { 264 | if (!enabled) { 265 | console.trace("[envoy-node] cannot get context when store is not enabled."); 266 | return undefined; 267 | } 268 | const asyncId = asyncHooks.executionAsyncId(); 269 | const context = getContext(asyncId); 270 | /* istanbul ignore next */ 271 | if (context === undefined) { 272 | console.trace( 273 | "[envoy-node] Cannot find info of current execution, have you enabled and set the context store correctly?" 274 | ); 275 | } 276 | return context; 277 | } 278 | 279 | /** 280 | * return if the store is enabled 281 | */ 282 | function isEnabled() { 283 | return enabled; 284 | } 285 | 286 | /** 287 | * return the instance of the store 288 | */ 289 | function getStoreImpl() { 290 | return store; 291 | } 292 | 293 | /** 294 | * return debug info about the store 295 | */ 296 | function getDebugInfo() { 297 | return `current size: ${store.size()}, old size: ${store.oldSize()}, not destroyed hooks: ${notDestroyedHooks}`; 298 | } 299 | 300 | export default { 301 | enable, 302 | disable, 303 | set, 304 | get, 305 | isEnabled, 306 | getStoreImpl, 307 | getEliminateInterval, 308 | setEliminateInterval, 309 | getDebugInfo, 310 | }; 311 | -------------------------------------------------------------------------------- /src/envoy-context.ts: -------------------------------------------------------------------------------- 1 | import { HttpHeader } from "./types"; 2 | import { Metadata } from "@grpc/grpc-js"; 3 | import { isNumber } from "util"; 4 | 5 | const ENVOY_DEFAULT_EGRESS_PORT = 12345; 6 | const ENVOY_DEFAULT_EGRESS_ADDR = "127.0.0.1"; 7 | 8 | const ENVOY_EGRESS_PORT = parseInt( 9 | process.env.ENVOY_EGRESS_PORT || `${ENVOY_DEFAULT_EGRESS_PORT}`, 10 | 10 11 | ); 12 | const ENVOY_EGRESS_ADDR = process.env.ENVOY_EGRESS_ADDR || ENVOY_DEFAULT_EGRESS_ADDR; 13 | 14 | const X_B3_TRACEID = "x-b3-traceid"; 15 | const X_B3_SPANID = "x-b3-spanid"; 16 | const X_B3_PARENTSPANID = "x-b3-parentspanid"; 17 | const X_B3_SAMPLED = "x-b3-sampled"; 18 | const X_B3_FLAGS = "x-b3-flags"; 19 | const X_OT_SPAN_CONTEXT = "x-ot-span-context"; 20 | const X_REQUEST_ID = "x-request-id"; 21 | const X_CLIENT_TRACE_ID = "x-client-trace-id"; 22 | 23 | const X_ENVOY_EXPECTED_RQ_TIMEOUT_MS = "x-envoy-expected-rq-timeout-ms"; 24 | 25 | /** 26 | * the header returned by envoy telling upstream is overloaded 27 | * @internal 28 | */ 29 | export const X_ENVOY_OVERLOADED = "x-envoy-overloaded"; 30 | /** 31 | * the header returned by envoy telling upstream duration 32 | * @internal 33 | */ 34 | export const X_ENVOY_UPSTREAM_SERVICE_TIME = "x-envoy-upstream-service-time"; 35 | 36 | /** 37 | * the header set in envoy config for telling this library egress port 38 | */ 39 | export const X_TUBI_ENVOY_EGRESS_PORT = "x-tubi-envoy-egress-port"; 40 | /** 41 | * the header set in envoy config for telling this library egress address 42 | */ 43 | export const X_TUBI_ENVOY_EGRESS_ADDR = "x-tubi-envoy-egress-addr"; 44 | 45 | /** 46 | * the optional header set in envoy config for telling a host is managed by envoy 47 | * so that this library can route envoy or call directly accordingly 48 | */ 49 | export const X_TUBI_ENVOY_MANAGED_HOST = "x-tubi-envoy-managed-host"; 50 | 51 | /** 52 | * read value of the key from meata 53 | * return undefined if not found or empty 54 | * return first one if multiple values 55 | * @param meta metadata 56 | * @param key key 57 | */ 58 | export function readMetaAsStringOrUndefined(meta: Metadata, key: string) { 59 | const value = meta.get(key); 60 | if (value.length > 0) { 61 | return value[0].toString(); 62 | } 63 | return undefined; 64 | } 65 | 66 | function alwaysReturnArray(input: string | string[]) { 67 | if (Array.isArray(input)) return input; 68 | return [input]; 69 | } 70 | 71 | /** 72 | * read value of the key from header 73 | * return undefined if not found or empty 74 | * return first one if multiple values 75 | * @param header the header 76 | * @param key the key 77 | */ 78 | export function readHeaderOrUndefined(header: HttpHeader, key: string) { 79 | const value = header[key]; 80 | if (!value) { 81 | return undefined; 82 | } 83 | return alwaysReturnArray(value)[0]; 84 | } 85 | 86 | /** 87 | * assign key value to header, skip empty value 88 | * @param header the http header 89 | * @param key the key 90 | * @param value the value 91 | */ 92 | export function assignHeader( 93 | header: HttpHeader, 94 | key: string, 95 | value: string | number | undefined | null 96 | ) { 97 | if (value === undefined || value === null) return; 98 | if (isNumber(value)) { 99 | if (isNaN(value)) { 100 | return; 101 | } 102 | header[key] = `${value}`; 103 | } else { 104 | header[key] = value; 105 | } 106 | } 107 | 108 | /** 109 | * params for init envoy context 110 | */ 111 | export interface EnvoyContextInit extends Object { 112 | /** 113 | * you can either give HTTP header for grpc.Metadata, it will be converted accordingly. 114 | */ 115 | meta: HttpHeader | Metadata; 116 | 117 | /** 118 | * optional egress port information 119 | * if not specified, it will be read from meta / environment variable ENVOY_EGRESS_PORT / 120 | * default value: 12345 (one after another) 121 | */ 122 | envoyEgressPort?: number; 123 | 124 | /** 125 | * optional egress address information 126 | * if not specified, it will be read from meta / environment variable ENVOY_EGRESS_ADDR / 127 | * default value: 127.0.0.1 (one after another) 128 | */ 129 | envoyEgressAddr?: string; 130 | 131 | /** 132 | * For easier migrate service to envoy step by step, we can route traffic to envoy for those service 133 | * migrated. Fill this set for the migrated service. 134 | * This field is default to `undefined` which means all traffic will be route to envoy. 135 | * If this field is set to `undefined`, this library will also try to read it from `x-tubi-envoy-managed-host`. 136 | * You can set in envoy config, like this: 137 | * 138 | * ```yaml 139 | * request_headers_to_add: 140 | * - key: x-tubi-envoy-managed-host 141 | * value: hostname:12345 142 | * - key: x-tubi-envoy-managed-host 143 | * value: foo.bar:8080 144 | * ``` 145 | * 146 | * If you set this to be an empty set, then no traffic will be route to envoy. 147 | */ 148 | envoyManagedHosts?: Set; 149 | 150 | /** 151 | * For dev or test environment, we usually don't have Envoy running. By setting directMode = true 152 | * will make all the traffic being sent directly. 153 | * If you set directMode to true, envoyManagedHosts will be ignored and set to an empty set. 154 | */ 155 | directMode?: boolean; 156 | } 157 | 158 | /** 159 | * some HTTP framework will do a tricky thing: the merge the headers into one string 160 | * fixing it here 161 | * @param hosts a list of array 162 | */ 163 | export function refineManagedHostArray(hosts: string[]) { 164 | return hosts.reduce((acc: string[], host: string) => { 165 | if (host.indexOf(",") >= 0) { 166 | return acc.concat( 167 | host 168 | .split(",") 169 | .map((value) => value.trim()) 170 | .filter((value) => value) 171 | ); 172 | } 173 | acc.push(host); 174 | return acc; 175 | }, []); 176 | } 177 | 178 | export function ensureItsEnvoyContextInit( 179 | param: Metadata | HttpHeader | EnvoyContextInit 180 | ): EnvoyContextInit { 181 | // test if this is a grpc.Metadata 182 | if (param instanceof Metadata) { 183 | return { 184 | meta: param, 185 | }; 186 | } 187 | 188 | // this if this is a HttpHeader 189 | const asInit = param as EnvoyContextInit; 190 | if (!asInit.meta || typeof asInit.meta === "string" || Array.isArray(asInit.meta)) { 191 | return { 192 | meta: param as HttpHeader, 193 | }; 194 | } 195 | 196 | return asInit; 197 | } 198 | 199 | /** 200 | * EnvoyContext is where all information related to the current envoy environment. 201 | */ 202 | export default class EnvoyContext { 203 | /** 204 | * the bind address of envoy egress 205 | */ 206 | readonly envoyEgressAddr: string; 207 | 208 | /** 209 | * The port local Envoy listening on for egress traffic. 210 | * (So all the egress will be sent to that port) 211 | */ 212 | readonly envoyEgressPort: number; 213 | 214 | /** 215 | * The x-b3-traceid HTTP header is used by the Zipkin tracer in Envoy. The TraceId 216 | * is 64-bit in length and indicates the overall ID of the trace. Every span in a 217 | * trace shares this ID. 218 | * See more on zipkin tracing here . 219 | */ 220 | readonly traceId?: string; 221 | 222 | /** 223 | * The x-b3-spanid HTTP header is used by the Zipkin tracer in Envoy. The SpanId is 224 | * 64-bit in length and indicates the position of the current operation in the trace 225 | * tree. The value should not be interpreted: it may or may not be derived from the 226 | * value of the TraceId. 227 | * See more on zipkin tracing here . 228 | */ 229 | readonly spanId?: string; 230 | 231 | /** 232 | * The x-b3-parentspanid HTTP header is used by the Zipkin tracer in Envoy. The 233 | * ParentSpanId is 64-bit in length and indicates the position of the parent operation 234 | * in the trace tree. When the span is the root of the trace tree, the ParentSpanId 235 | * is absent. 236 | * See more on zipkin tracing here . 237 | */ 238 | readonly parentSpanId?: string; 239 | 240 | /** 241 | * The x-b3-sampled HTTP header is used by the Zipkin tracer in Envoy. When the Sampled 242 | * flag is 1, the soan will be reported to the tracing system. Once Sampled is set to 243 | * 0 or 1, the same value should be consistently sent downstream. 244 | * See more on zipkin tracing here . 245 | */ 246 | readonly sampled?: string; 247 | 248 | /** 249 | * The x-b3-flags HTTP header is used by the Zipkin tracer in Envoy. The encode one or 250 | * more options. For example, Debug is encoded as X-B3-Flags: 1. 251 | * See more on zipkin tracing here . 252 | */ 253 | readonly flags?: string; 254 | 255 | /** 256 | * The x-ot-span-context HTTP header is used by Envoy to establish proper parent-child 257 | * relationships between tracing spans. This header can be used with both LightStep and 258 | * Zipkin tracers. For example, an egress span is a child of an ingress span (if the 259 | * ingress span was present). Envoy injects the x-ot-span-context header on ingress 260 | * requests and forwards it to the local service. Envoy relies on the application to 261 | * propagate x-ot-span-context on the egress call to an upstream. 262 | * See more on tracing here . 263 | */ 264 | readonly otSpanContext?: string; 265 | 266 | /** 267 | * The x-request-id header is used by Envoy to uniquely identify a request as well as 268 | * perform stable access logging and tracing. Envoy will generate an x-request-id header 269 | * for all external origin requests (the header is sanitized). It will also generate an 270 | * x-request-id header for internal requests that do not already have one. This means that 271 | * x-request-id can and should be propagated between client applications in order to have 272 | * stable IDs across the entire mesh. Due to the out of process architecture of Envoy, 273 | * the header can not be automatically forwarded by Envoy itself. This is one of the few 274 | * areas where a thin client library is needed to perform this duty. How that is done is 275 | * out of scope for this documentation. If x-request-id is propagated across all hosts, 276 | * the following features are available: 277 | * - Stable access logging via the v1 API runtime filter or the v2 API runtime filter. 278 | * - Stable tracing when performing random sampling via the tracing.random_sampling runtime 279 | * setting or via forced tracing using the x-envoy-force-trace and x-client-trace-id headers. 280 | */ 281 | readonly requestId?: string; 282 | 283 | /** 284 | * If an external client sets this header, Envoy will join the provided trace ID with 285 | * the internally generated x-request-id. 286 | */ 287 | readonly clientTraceId?: string; 288 | 289 | /** 290 | * This is the time in milliseconds the router expects the request to be completed. Envoy 291 | * sets this header so that the upstream host receiving the request can make decisions based 292 | * on the request timeout, e.g., early exit. This is set on internal requests and is either 293 | * taken from the x-envoy-upstream-rq-timeout-ms header or the route timeout, in that order. 294 | */ 295 | readonly expectedRequestTimeout?: number; 296 | 297 | /** 298 | * For dev or test environment, we usually don't have Envoy running. By setting directMode = true 299 | * will make all the traffic being sent directly. 300 | * If you set directMode to true, envoyManagedHosts will be ignored and set to an empty set. 301 | */ 302 | private readonly directMode: boolean; 303 | 304 | /** 305 | * For easier migrate service to envoy step by step, we can route traffic to envoy for those service 306 | * migrated. Fill this set for the migrated service. 307 | * This field is default to `undefined` which means all traffic will be route to envoy. 308 | * If this field is set to `undefined`, this library will also try to read it from `x-tubi-envoy-managed-host`. 309 | * You can set in envoy config, like this: 310 | * 311 | * ```yaml 312 | * request_headers_to_add: 313 | * - key: x-tubi-envoy-managed-host 314 | * value: hostname:12345 315 | * - key: x-tubi-envoy-managed-host 316 | * value: foo.bar:8080 317 | * ``` 318 | * 319 | * If you set this to be an empty set, then no traffic will be route to envoy. 320 | */ 321 | private readonly envoyManagedHosts?: Set; 322 | 323 | /** 324 | * initialize an EnvoyContext 325 | * @param options options for init envoy context 326 | */ 327 | constructor(options: Metadata | HttpHeader | EnvoyContextInit) { 328 | const { 329 | meta, 330 | envoyEgressPort, 331 | envoyEgressAddr, 332 | envoyManagedHosts, 333 | directMode, 334 | } = ensureItsEnvoyContextInit(options); 335 | 336 | let expectedRequestTimeoutString: string | undefined; 337 | let envoyEgressAddrFromHeader: string | undefined; 338 | let envoyEgressPortStringFromHeader: string | undefined; 339 | let envoyManagedHostsFromHeader: string[] | undefined; 340 | 341 | if (meta instanceof Metadata) { 342 | const metadata: Metadata = meta; 343 | this.traceId = readMetaAsStringOrUndefined(metadata, X_B3_TRACEID); 344 | this.spanId = readMetaAsStringOrUndefined(metadata, X_B3_SPANID); 345 | this.parentSpanId = readMetaAsStringOrUndefined(metadata, X_B3_PARENTSPANID); 346 | this.sampled = readMetaAsStringOrUndefined(metadata, X_B3_SAMPLED); 347 | this.flags = readMetaAsStringOrUndefined(metadata, X_B3_FLAGS); 348 | this.otSpanContext = readMetaAsStringOrUndefined(metadata, X_OT_SPAN_CONTEXT); 349 | this.requestId = readMetaAsStringOrUndefined(metadata, X_REQUEST_ID); 350 | this.clientTraceId = readMetaAsStringOrUndefined(metadata, X_CLIENT_TRACE_ID); 351 | expectedRequestTimeoutString = readMetaAsStringOrUndefined( 352 | metadata, 353 | X_ENVOY_EXPECTED_RQ_TIMEOUT_MS 354 | ); 355 | envoyEgressAddrFromHeader = readMetaAsStringOrUndefined(metadata, X_TUBI_ENVOY_EGRESS_ADDR); 356 | envoyEgressPortStringFromHeader = readMetaAsStringOrUndefined( 357 | metadata, 358 | X_TUBI_ENVOY_EGRESS_PORT 359 | ); 360 | const managedHosts = metadata.get(X_TUBI_ENVOY_MANAGED_HOST); 361 | if (managedHosts && managedHosts.length > 0) { 362 | envoyManagedHostsFromHeader = managedHosts.map((v) => v.toString()); 363 | } 364 | } else { 365 | const httpHeader: HttpHeader = meta; 366 | this.traceId = readHeaderOrUndefined(httpHeader, X_B3_TRACEID); 367 | this.spanId = readHeaderOrUndefined(httpHeader, X_B3_SPANID); 368 | this.parentSpanId = readHeaderOrUndefined(httpHeader, X_B3_PARENTSPANID); 369 | this.sampled = readHeaderOrUndefined(httpHeader, X_B3_SAMPLED); 370 | this.flags = readHeaderOrUndefined(httpHeader, X_B3_FLAGS); 371 | this.otSpanContext = readHeaderOrUndefined(httpHeader, X_OT_SPAN_CONTEXT); 372 | this.requestId = readHeaderOrUndefined(httpHeader, X_REQUEST_ID); 373 | this.clientTraceId = readHeaderOrUndefined(httpHeader, X_CLIENT_TRACE_ID); 374 | expectedRequestTimeoutString = readHeaderOrUndefined( 375 | httpHeader, 376 | X_ENVOY_EXPECTED_RQ_TIMEOUT_MS 377 | ); 378 | envoyEgressAddrFromHeader = readHeaderOrUndefined(httpHeader, X_TUBI_ENVOY_EGRESS_ADDR); 379 | envoyEgressPortStringFromHeader = readHeaderOrUndefined(httpHeader, X_TUBI_ENVOY_EGRESS_PORT); 380 | const managedHosts = httpHeader[X_TUBI_ENVOY_MANAGED_HOST]; 381 | if (managedHosts) { 382 | envoyManagedHostsFromHeader = alwaysReturnArray(managedHosts); 383 | } 384 | } 385 | 386 | if (expectedRequestTimeoutString !== undefined && expectedRequestTimeoutString !== "") { 387 | this.expectedRequestTimeout = parseInt(expectedRequestTimeoutString, 10); 388 | } 389 | 390 | this.envoyEgressPort = 391 | envoyEgressPort || 392 | (envoyEgressPortStringFromHeader && parseInt(envoyEgressPortStringFromHeader, 10)) || 393 | ENVOY_EGRESS_PORT; 394 | this.envoyEgressAddr = envoyEgressAddr || envoyEgressAddrFromHeader || ENVOY_EGRESS_ADDR; 395 | 396 | if (directMode === undefined) { 397 | this.directMode = 398 | process.env.ENVOY_DIRECT_MODE === "true" || process.env.ENVOY_DIRECT_MODE === "1"; 399 | } else { 400 | this.directMode = directMode; 401 | } 402 | 403 | if (this.directMode) { 404 | this.envoyManagedHosts = new Set(); 405 | } else if (envoyManagedHosts !== undefined) { 406 | this.envoyManagedHosts = envoyManagedHosts; 407 | } else if (envoyManagedHostsFromHeader !== undefined) { 408 | this.envoyManagedHosts = new Set(refineManagedHostArray(envoyManagedHostsFromHeader)); 409 | } 410 | } 411 | 412 | /** 413 | * Assemble the required tracing headers that required for propagation. 414 | * See more here 415 | */ 416 | assembleTracingHeader(): HttpHeader { 417 | const header: HttpHeader = {}; 418 | assignHeader(header, X_B3_TRACEID, this.traceId); 419 | assignHeader(header, X_B3_SPANID, this.spanId); 420 | assignHeader(header, X_B3_PARENTSPANID, this.parentSpanId); 421 | assignHeader(header, X_B3_SAMPLED, this.sampled); 422 | assignHeader(header, X_B3_FLAGS, this.flags); 423 | assignHeader(header, X_OT_SPAN_CONTEXT, this.otSpanContext); 424 | assignHeader(header, X_REQUEST_ID, this.requestId); 425 | assignHeader(header, X_CLIENT_TRACE_ID, this.clientTraceId); 426 | return header; 427 | } 428 | 429 | shouldCallWithoutEnvoy(host: string): boolean { 430 | return ( 431 | this.directMode || (this.envoyManagedHosts !== undefined && !this.envoyManagedHosts.has(host)) 432 | ); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/envoy-fetch.ts: -------------------------------------------------------------------------------- 1 | import fetch, { RequestInit, Response } from "node-fetch"; 2 | import { parse as parseUrl } from "url"; 3 | 4 | import EnvoyContext, { X_ENVOY_OVERLOADED, X_ENVOY_UPSTREAM_SERVICE_TIME } from "./envoy-context"; 5 | import { HttpHeader } from "./types"; 6 | import EnvoyHttpRequestParams from "./envoy-http-request-params"; 7 | 8 | /** 9 | * EnvoyResponse is a little enhanced from the original Response of node-fetch 10 | */ 11 | export interface EnvoyResponse extends Response { 12 | /** 13 | * Envoy will set this header on the downstream response if a request was dropped due 14 | * to either maintenance mode or upstream circuit breaking. 15 | */ 16 | overloaded: boolean; 17 | /** 18 | * Contains the time in milliseconds spent by the upstream host processing the request. 19 | * This is useful if the client wants to determine service time compared to network latency. 20 | * This header is set on responses. 21 | */ 22 | upstreamServiceTime: number; 23 | } 24 | 25 | /** 26 | * the fetch function share most of the signature of the original node-fetch 27 | * but helps you on setting up the request being send to envoy egress port 28 | * @param envoyParams the params of envoy context as well as request control params (timeout / retry, etc) 29 | * @param url the target url, the same as node-fetch's first param 30 | * @param init the init, the same as node-fetch's second param 31 | */ 32 | export default async function envoyFetch( 33 | envoyParams: EnvoyHttpRequestParams, 34 | url: string, 35 | init?: RequestInit 36 | ): Promise { 37 | const { protocol, host, path } = parseUrl(url); 38 | if (!protocol || !host || !path) { 39 | throw new Error("Cannot read the URL for envoy to fetch"); 40 | } 41 | 42 | const callDirectly = envoyParams.context.shouldCallWithoutEnvoy(host); 43 | 44 | if (protocol !== "http:" && protocol !== "https:" && !callDirectly) { 45 | throw new Error(`envoy fetch is designed only for http / https for now, current found: ${url}`); 46 | } 47 | const refinedInit: RequestInit = { ...init }; 48 | 49 | const oldHeaders: HttpHeader = {}; 50 | Object.assign(oldHeaders, refinedInit.headers); 51 | 52 | refinedInit.headers = { 53 | ...oldHeaders, 54 | ...envoyParams.assembleRequestHeaders(), 55 | host 56 | }; 57 | const actualUrl = callDirectly 58 | ? url 59 | : `http://${envoyParams.context.envoyEgressAddr}:${envoyParams.context.envoyEgressPort}${path}`; 60 | const response = await fetch(actualUrl, refinedInit); 61 | 62 | const upstreamTimeHeader = response.headers.get(X_ENVOY_UPSTREAM_SERVICE_TIME); 63 | const upstreamServiceTime = upstreamTimeHeader === null ? NaN : parseInt(upstreamTimeHeader, 10); 64 | 65 | /* tslint:disable:prefer-object-spread */ 66 | const envoyResponse: EnvoyResponse = Object.assign(response, { 67 | overloaded: response.headers.has(X_ENVOY_OVERLOADED), 68 | upstreamServiceTime 69 | }); 70 | /* tslint:enable:prefer-object-spread */ 71 | 72 | return envoyResponse; 73 | } 74 | -------------------------------------------------------------------------------- /src/envoy-grpc-request-params.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "@grpc/grpc-js"; 2 | 3 | import EnvoyRequestParams, { 4 | X_ENVOY_MAX_RETRIES, 5 | X_ENVOY_UPSTREAM_RQ_TIMEOUT_MS, 6 | X_ENVOY_UPSTREAM_RQ_PER_TRY_TIMEOUT_MS, 7 | } from "./envoy-request-params"; 8 | import EnvoyContext from "./envoy-context"; 9 | import { HttpHeader } from "./types"; 10 | 11 | const X_ENVOY_RETRY_GRPC_ON = "x-envoy-retry-grpc-on"; 12 | 13 | /** 14 | * Setting this header on egress requests will cause Envoy to attempt to retry 15 | * failed requests (number of retries defaults to 1, and can be controlled by 16 | * x-envoy-max-retries header or the route config retry policy). gRPC retries 17 | * are currently only supported for gRPC status codes in response headers. gRPC 18 | * status codes in trailers will not trigger retry logic. 19 | */ 20 | export enum GrpcRetryOn { 21 | /** 22 | * Envoy will attempt a retry if the gRPC status code in the response headers is 23 | * “cancelled” (1) 24 | */ 25 | CANCELLED = "cancelled", 26 | 27 | /** 28 | * Envoy will attempt a retry if the gRPC status code in the response headers is 29 | * “deadline-exceeded” (4) 30 | */ 31 | DEADLINE_EXCEEDED = "deadline-exceeded", 32 | 33 | /** 34 | * Envoy will attempt a retry if the gRPC status code in the response headers is 35 | * “resource-exhausted” (8) 36 | */ 37 | RESOURCE_EXHAUSTED = "resource-exhausted", 38 | } 39 | 40 | /** 41 | * request params: timeout, retry, etc. 42 | */ 43 | export interface EnvoyGrpcRequestInit { 44 | maxRetries?: number; 45 | retryOn?: GrpcRetryOn[]; 46 | timeout?: number; 47 | perTryTimeout?: number; 48 | headers?: HttpHeader; 49 | } 50 | 51 | /** 52 | * convert http header to grpc.Metadata 53 | * @param httpHeader the http header 54 | * @internal 55 | */ 56 | export function httpHeader2Metadata(httpHeader: HttpHeader) { 57 | const metadata = new Metadata(); 58 | for (const [key, value] of Object.entries(httpHeader)) { 59 | if (Array.isArray(value)) { 60 | value.forEach((v) => metadata.add(key, v)); 61 | } else { 62 | metadata.add(key, value); 63 | } 64 | } 65 | return metadata; 66 | } 67 | 68 | /** 69 | * the gRPC request params, mainly two parts: 70 | * 1. EnvoyContext, telling what the situation is 71 | * 2. request params, like timeout, retry, etc. 72 | */ 73 | export default class EnvoyGrpcRequestParams extends EnvoyRequestParams { 74 | /** 75 | * on what condition shall envoy retry 76 | */ 77 | readonly retryOn: GrpcRetryOn[]; 78 | 79 | /** 80 | * Setting the retry policies, if empty param is given will not generate any headers but using 81 | * the default setting in Envoy's config 82 | * @param params the params for initialize the request params 83 | */ 84 | constructor(context: EnvoyContext, params?: EnvoyGrpcRequestInit) { 85 | const { maxRetries, retryOn, timeout, perTryTimeout, headers }: EnvoyGrpcRequestInit = { 86 | maxRetries: -1, 87 | retryOn: [], 88 | timeout: -1, 89 | perTryTimeout: -1, 90 | headers: {}, 91 | ...params, 92 | }; 93 | super(context, maxRetries, timeout, perTryTimeout, headers); 94 | this.retryOn = retryOn; 95 | } 96 | 97 | /** 98 | * assemble the request headers for setting retry. 99 | */ 100 | assembleRequestMeta(): Metadata { 101 | const metadata = httpHeader2Metadata({ 102 | ...this.context.assembleTracingHeader(), 103 | ...this.customHeaders, 104 | }); 105 | 106 | if (this.maxRetries >= 0) { 107 | metadata.add(X_ENVOY_MAX_RETRIES, `${this.maxRetries}`); 108 | } 109 | 110 | if (this.maxRetries > 0) { 111 | metadata.add(X_ENVOY_RETRY_GRPC_ON, this.retryOn.join(",")); 112 | } 113 | 114 | if (this.timeout > 0) { 115 | metadata.add(X_ENVOY_UPSTREAM_RQ_TIMEOUT_MS, `${this.timeout}`); 116 | } 117 | 118 | if (this.perTryTimeout > 0) { 119 | metadata.add(X_ENVOY_UPSTREAM_RQ_PER_TRY_TIMEOUT_MS, `${this.perTryTimeout}`); 120 | } 121 | 122 | return metadata; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/envoy-http-client.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "@grpc/grpc-js"; 2 | import { HttpHeader } from "./types"; 3 | import EnvoyHttpRequestParams, { EnvoyHttpRequestInit } from "./envoy-http-request-params"; 4 | import EnvoyContext from "./envoy-context"; 5 | import envoyFetch, { EnvoyResponse } from "./envoy-fetch"; 6 | 7 | /** 8 | * json header 9 | * @internal 10 | */ 11 | export const APPLICATION_JSON = "application/json"; 12 | 13 | /** 14 | * a high level http client that will send traffic to Envoy: 15 | * 1. all HTTP GET / POST ... will be wrapped as async signature 16 | * 2. doing JSON RESET API common works: accepting object, return objects, only works with `application/json` 17 | * 3. none 2XX request will be throw as Error 18 | */ 19 | export default class EnvoyHttpClient { 20 | /** 21 | * the envoy context where you can read 22 | */ 23 | envoyContext: EnvoyContext; 24 | 25 | /** 26 | * init the client 27 | * @param ctx context, you can give either EnvoyContext, Metadata or HTTP Header 28 | * the last two will be convert to EnvoyContext 29 | */ 30 | constructor(ctx: EnvoyContext | Metadata | HttpHeader) { 31 | if (ctx instanceof EnvoyContext) { 32 | this.envoyContext = ctx; 33 | } else { 34 | this.envoyContext = new EnvoyContext(ctx); 35 | } 36 | } 37 | 38 | /** 39 | * the common logic for json processing and error handling 40 | * @param response response from fetch 41 | */ 42 | private async returnJsonOrError(response: EnvoyResponse): Promise { 43 | // TODO considering return the following metadata: 44 | // response.overloaded 45 | // response.upstreamServiceTime 46 | const $statusCode = response.status; 47 | if ($statusCode === 204) { 48 | return undefined; 49 | } 50 | const contentType = response.headers.get("content-type"); 51 | if (!contentType || (contentType !== "application/json" && !contentType.startsWith("text/"))) { 52 | const err = new Error(`Unexpected content type: ${contentType}, http status: ${$statusCode}`); 53 | const body = await response.text(); 54 | Object.assign(err, { $statusCode, body }); 55 | throw err; 56 | } 57 | if (contentType === "application/json") { 58 | const result = await response.json(); 59 | if ($statusCode >= 400) { 60 | result.$statusCode = result.$statusCode || $statusCode; 61 | throw result; 62 | } 63 | return result; 64 | } 65 | const text = await response.text(); 66 | if ($statusCode !== 200) { 67 | const error = new Error(text); 68 | Object.assign(error, { $statusCode }); 69 | throw error; 70 | } 71 | return text; 72 | } 73 | 74 | async actionWithoutBody(method: string, url: string, init?: EnvoyHttpRequestInit): Promise { 75 | const param = new EnvoyHttpRequestParams(this.envoyContext, init); 76 | const res = await envoyFetch(param, url, { 77 | method, 78 | headers: { 79 | accept: APPLICATION_JSON, 80 | }, 81 | }); 82 | return this.returnJsonOrError(res); 83 | } 84 | 85 | /** 86 | * send a GET request and expecting return json or empty 87 | * @param url the URL to get 88 | * @param init the params for the request, like retry, timeout 89 | * @throws Error for none 2XX request, a $statusCode will be available in the error object 90 | */ 91 | async get(url: string, init?: EnvoyHttpRequestInit): Promise { 92 | return this.actionWithoutBody("GET", url, init); 93 | } 94 | 95 | /** 96 | * send a DELETE request and expecting return json or empty 97 | * @param url the URL to get 98 | * @param init the params for the request, like retry, timeout 99 | * @throws Error for none 2XX request, a $statusCode will be available in the error object 100 | */ 101 | async delete(url: string, init?: EnvoyHttpRequestInit): Promise { 102 | return this.actionWithoutBody("DELETE", url, init); 103 | } 104 | 105 | async actionWithBody( 106 | method: string, 107 | url: string, 108 | body: any, 109 | init?: EnvoyHttpRequestInit 110 | ): Promise { 111 | const param = new EnvoyHttpRequestParams(this.envoyContext, init); 112 | const res = await envoyFetch(param, url, { 113 | method, 114 | headers: { 115 | "content-type": APPLICATION_JSON, 116 | // tslint:disable-next-line:object-literal-key-quotes 117 | accept: APPLICATION_JSON, 118 | }, 119 | body: JSON.stringify(body), 120 | }); 121 | return this.returnJsonOrError(res); 122 | } 123 | 124 | /** 125 | * send a POST request and expecting return json or empty 126 | * @param url the URL to get 127 | * @param body the request object, will be serialize to JSON when sending out 128 | * @param init the params for the request, like retry, timeout 129 | * @throws Error for none 2XX request, a $statusCode will be available in the error object 130 | */ 131 | async post(url: string, body: any, init?: EnvoyHttpRequestInit): Promise { 132 | return this.actionWithBody("POST", url, body, init); 133 | } 134 | 135 | /** 136 | * send a PATCH request and expecting return json or empty 137 | * @param url the URL to get 138 | * @param body the request object, will be serialize to JSON when sending out 139 | * @param init the params for the request, like retry, timeout 140 | * @throws Error for none 2XX request, a $statusCode will be available in the error object 141 | */ 142 | async patch(url: string, body: any, init?: EnvoyHttpRequestInit): Promise { 143 | return this.actionWithBody("PATCH", url, body, init); 144 | } 145 | 146 | /** 147 | * send a PUT request and expecting return json or empty 148 | * @param url the URL to get 149 | * @param body the request object, will be serialize to JSON when sending out 150 | * @param init the params for the request, like retry, timeout 151 | * @throws Error for none 2XX request, a $statusCode will be available in the error object 152 | */ 153 | async put(url: string, body: any, init?: EnvoyHttpRequestInit): Promise { 154 | return this.actionWithBody("PUT", url, body, init); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/envoy-http-request-params.ts: -------------------------------------------------------------------------------- 1 | import EnvoyRequestParams, { 2 | X_ENVOY_MAX_RETRIES, 3 | X_ENVOY_UPSTREAM_RQ_TIMEOUT_MS, 4 | X_ENVOY_UPSTREAM_RQ_PER_TRY_TIMEOUT_MS 5 | } from "./envoy-request-params"; 6 | import { HttpHeader } from "./types"; 7 | import EnvoyContext from "./envoy-context"; 8 | 9 | const X_ENVOY_RETRY_ON = "x-envoy-retry-on"; 10 | 11 | /** 12 | * Setting this header on egress requests will cause Envoy to attempt to retry failed 13 | * requests (number of retries defaults to 1 and can be controlled by x-envoy-max-retries 14 | * header or the route config retry policy). The value to which the x-envoy-retry-on header 15 | * is set indicates the retry policy. 16 | */ 17 | export enum HttpRetryOn { 18 | /** 19 | * Envoy will attempt a retry if the upstream server responds with any 5xx response code, 20 | * or does not respond at all (disconnect/reset/read timeout). (Includes connect-failure 21 | * and refused-stream) 22 | * 23 | * NOTE: Envoy will not retry when a request exceeds x-envoy-upstream-rq-timeout-ms 24 | * (resulting in a 504 error code). Use x-envoy-upstream-rq-per-try-timeout-ms if you want 25 | * to retry when individual attempts take too long. x-envoy-upstream-rq-timeout-ms is an 26 | * outer time limit for a request, including any retries that take place. 27 | */ 28 | HTTP_5XX = "5xx", 29 | 30 | /** 31 | * Envoy will attempt a retry if a request is failed because of a connection failure to the 32 | * upstream server (connect timeout, etc.). (Included in 5xx) 33 | * 34 | * NOTE: A connection failure/timeout is a the TCP level, not the request level. This does 35 | * not include upstream request timeouts specified via x-envoy-upstream-rq-timeout-ms or via 36 | * route configuration. 37 | */ 38 | CONNECT_FAILURE = "connect-failure", 39 | 40 | /** 41 | * Envoy will attempt a retry if the upstream server responds with a retriable 4xx response 42 | * code. Currently, the only response code in this category is 409. 43 | * 44 | * NOTE: Be careful turning on this retry type. There are certain cases where a 409 can 45 | * indicate that an optimistic locking revision needs to be updated. Thus, the caller should 46 | * not retry and needs to read then attempt another write. If a retry happens in this type of 47 | * case it will always fail with another 409. 48 | */ 49 | RETRIABLE_4XX = "retriable-4xx", 50 | 51 | /** 52 | * Envoy will attempt a retry if the upstream server resets the stream with a REFUSED_STREAM 53 | * error code. This reset type indicates that a request is safe to retry. (Included in 5xx) 54 | */ 55 | REFUSED_STREAM = "refused-stream" 56 | } 57 | 58 | /** 59 | * request params: timeout, retry, etc. 60 | */ 61 | export interface EnvoyHttpRequestInit { 62 | maxRetries?: number; 63 | retryOn?: HttpRetryOn[]; 64 | timeout?: number; 65 | perTryTimeout?: number; 66 | headers?: HttpHeader; 67 | } 68 | 69 | /** 70 | * the HTTP request params, mainly two parts: 71 | * 1. EnvoyContext, telling what the situation is 72 | * 2. request params, like timeout, retry, etc. 73 | */ 74 | export default class EnvoyHttpRequestParams extends EnvoyRequestParams { 75 | /** 76 | * on what condition shall envoy retry 77 | */ 78 | readonly retryOn: HttpRetryOn[]; 79 | 80 | /** 81 | * Setting the retry policies, if empty param is given will not generate any headers but using 82 | * the default setting in Envoy's config 83 | * @param params the params for initialize the request params 84 | */ 85 | constructor(context: EnvoyContext, params?: EnvoyHttpRequestInit) { 86 | const { maxRetries, retryOn, timeout, perTryTimeout, headers }: EnvoyHttpRequestInit = { 87 | maxRetries: -1, 88 | retryOn: [], 89 | timeout: -1, 90 | perTryTimeout: -1, 91 | headers: {}, 92 | ...params 93 | }; 94 | super(context, maxRetries, timeout, perTryTimeout, headers); 95 | this.retryOn = retryOn; 96 | } 97 | 98 | /** 99 | * assemble the request headers for setting retry. 100 | * TODO: in direct mode, we may need to modify the tracing headers 101 | */ 102 | assembleRequestHeaders(): HttpHeader { 103 | const header: HttpHeader = { 104 | ...this.context.assembleTracingHeader(), 105 | ...this.customHeaders 106 | }; 107 | 108 | if (this.maxRetries >= 0) { 109 | header[X_ENVOY_MAX_RETRIES] = `${this.maxRetries}`; 110 | } 111 | 112 | if (this.maxRetries > 0) { 113 | header[X_ENVOY_RETRY_ON] = this.retryOn.join(","); 114 | } 115 | 116 | if (this.timeout > 0) { 117 | header[X_ENVOY_UPSTREAM_RQ_TIMEOUT_MS] = `${this.timeout}`; 118 | } 119 | 120 | if (this.perTryTimeout > 0) { 121 | header[X_ENVOY_UPSTREAM_RQ_PER_TRY_TIMEOUT_MS] = `${this.perTryTimeout}`; 122 | } 123 | 124 | return header; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/envoy-node.ts: -------------------------------------------------------------------------------- 1 | export { default as EnvoyContext } from "./envoy-context"; 2 | export { default as envoyFetch } from "./envoy-fetch"; 3 | export { default as EnvoyGrpcRequestParams, GrpcRetryOn } from "./envoy-grpc-request-params"; 4 | export { default as EnvoyHttpClient } from "./envoy-http-client"; 5 | export { default as EnvoyHttpRequestParams, HttpRetryOn } from "./envoy-http-request-params"; 6 | export { default as envoyProtoDecorator } from "./envoy-proto-decorator"; 7 | export { default as envoyRequestParamsRefiner } from "./envoy-request-params-refiner"; 8 | export { default as envoyContextStore } from "./envoy-context-store"; 9 | -------------------------------------------------------------------------------- /src/envoy-proto-decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | credentials, 3 | ServiceDefinition, 4 | Metadata, 5 | ServiceError, 6 | requestCallback, 7 | ClientWritableStream, 8 | ClientReadableStream, 9 | ClientDuplexStream, 10 | } from "@grpc/grpc-js"; 11 | import EnvoyContext from "./envoy-context"; 12 | import EnvoyGrpcRequestParams, { EnvoyGrpcRequestInit } from "./envoy-grpc-request-params"; 13 | import { 14 | RequestFunc, 15 | EnvoyClient, 16 | ClientConstructor, 17 | EnvoyClientConstructor, 18 | EnvoyClientFuncEnabled, 19 | HttpHeader, 20 | ChannelFactoryOverride, 21 | } from "./types"; 22 | 23 | /** 24 | * this function is to assign new method to the decorated original client 25 | * by assigning new method, user can call the method easier with async signature 26 | * @param name the function name 27 | * @internal 28 | */ 29 | function makeAsyncFunc(name: string): RequestFunc { 30 | return async function (this: EnvoyClient, request: any, options?: EnvoyGrpcRequestInit) { 31 | const params = new EnvoyGrpcRequestParams(this.envoyContext, options); 32 | return new Promise((resolve, reject) => { 33 | Object.getPrototypeOf(Object.getPrototypeOf(this))[name].call( 34 | this, 35 | request, 36 | params.assembleRequestMeta(), 37 | { 38 | host: this.originalAddress, 39 | }, 40 | (error: ServiceError, response: any) => { 41 | if (error) { 42 | reject(error); 43 | return; 44 | } 45 | resolve(response); 46 | } 47 | ); 48 | }); 49 | }; 50 | } 51 | 52 | /** 53 | * this is to wrap the original client stream func, to insert envoy metadata 54 | * @param name the name of the func 55 | */ 56 | function wrapClientStreamFunc(name: string) { 57 | return function ( 58 | this: EnvoyClient, 59 | callback: requestCallback, 60 | options?: EnvoyGrpcRequestInit 61 | ): ClientWritableStream { 62 | const params = new EnvoyGrpcRequestParams(this.envoyContext, options); 63 | return Object.getPrototypeOf(Object.getPrototypeOf(this))[name].call( 64 | this, 65 | params.assembleRequestMeta(), 66 | { 67 | host: this.originalAddress, 68 | }, 69 | callback 70 | ); 71 | }; 72 | } 73 | 74 | /** 75 | * this is to wrap the original server stream func, to insert envoy metadata 76 | * @param name the name of the func 77 | */ 78 | function wrapServerStream(name: string) { 79 | return function ( 80 | this: EnvoyClient, 81 | request: any, 82 | options?: EnvoyGrpcRequestInit 83 | ): ClientReadableStream { 84 | const params = new EnvoyGrpcRequestParams(this.envoyContext, options); 85 | return Object.getPrototypeOf(Object.getPrototypeOf(this))[name].call( 86 | this, 87 | request, 88 | params.assembleRequestMeta(), 89 | { 90 | host: this.originalAddress, 91 | } 92 | ); 93 | }; 94 | } 95 | 96 | /** 97 | * this is to wrap the original bidirectional stream, to insert envoy metadata 98 | * @param name the func name 99 | */ 100 | function wrapBidiStream(name: string) { 101 | return function ( 102 | this: EnvoyClient, 103 | options?: EnvoyGrpcRequestInit 104 | ): ClientDuplexStream { 105 | const params = new EnvoyGrpcRequestParams(this.envoyContext, options); 106 | return Object.getPrototypeOf(Object.getPrototypeOf(this))[name].call( 107 | this, 108 | params.assembleRequestMeta(), 109 | { 110 | host: this.originalAddress, 111 | } 112 | ); 113 | }; 114 | } 115 | 116 | /** 117 | * this method will decorate the client constructor to 118 | * 1. enable envoy context 119 | * 2. using async syntax for each call RPC 120 | * 121 | * Check `EnvoyClient` for more information 122 | * 123 | * TODO: optimize the typing if the typing of gRPC is updated 124 | * @param constructor Client constructor 125 | */ 126 | export default function envoyProtoDecorator( 127 | constructor: ClientConstructor, 128 | channelFactoryOverride: ChannelFactoryOverride | undefined = undefined 129 | ): EnvoyClientConstructor { 130 | const constructorAlias: any = constructor; 131 | const { service }: { service: ServiceDefinition } = constructorAlias; 132 | const clazz = class extends constructor implements EnvoyClient { 133 | readonly originalAddress: string; 134 | readonly envoyContext: EnvoyContext; 135 | 136 | constructor(address: string, ctx: EnvoyContext | Metadata | HttpHeader) { 137 | let envoyContext: EnvoyContext; 138 | if (ctx instanceof EnvoyContext) { 139 | envoyContext = ctx; 140 | } else { 141 | envoyContext = new EnvoyContext(ctx); 142 | } 143 | const actualAddr = envoyContext.shouldCallWithoutEnvoy(address) 144 | ? address 145 | : `${envoyContext.envoyEgressAddr}:${envoyContext.envoyEgressPort}`; 146 | super(actualAddr, credentials.createInsecure(), { channelFactoryOverride }); 147 | this.originalAddress = address; 148 | this.envoyContext = envoyContext; 149 | } 150 | } as EnvoyClientConstructor; 151 | 152 | const prototype = clazz.prototype as EnvoyClientFuncEnabled; 153 | 154 | for (const name of Object.keys(service)) { 155 | const method: any = service[name]; 156 | 157 | const { requestStream, responseStream } = method; 158 | 159 | if (!requestStream && !responseStream) { 160 | // tslint:disable-next-line:only-arrow-functions 161 | prototype[name] = makeAsyncFunc(name); 162 | } else if (method.requestStream && !method.responseStream) { 163 | prototype[name] = wrapClientStreamFunc(name); 164 | } else if (!method.requestStream && method.responseStream) { 165 | prototype[name] = wrapServerStream(name); 166 | } else { 167 | prototype[name] = wrapBidiStream(name); 168 | } 169 | 170 | const { originalName }: { originalName?: string } = method; 171 | /* istanbul ignore next */ 172 | if (originalName) { 173 | // should alway have 174 | prototype[originalName] = prototype[name]; 175 | } 176 | } 177 | 178 | return clazz; 179 | } 180 | -------------------------------------------------------------------------------- /src/envoy-request-params-refiner.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "@grpc/grpc-js"; 2 | import { parse as parseUrl } from "url"; 3 | import { HttpHeader } from "./types"; 4 | import EnvoyContext from "./envoy-context"; 5 | import EnvoyHttpRequestParams, { EnvoyHttpRequestInit } from "./envoy-http-request-params"; 6 | import { Options, OptionsWithUrl, OptionsWithUri } from "request"; 7 | 8 | /** 9 | * to easier migrate from http request using request library, you can use this function 10 | * to refine the request params directly 11 | * @param params request params, can be url string or params object 12 | * @param ctx the context, can be EnvoyContext, grpc.Metadata or HttpHeader 13 | * @param init the extra options for the request 14 | */ 15 | export default function envoyRequestParamsRefiner( 16 | params: string | Options, 17 | ctx?: EnvoyContext | Metadata | HttpHeader, 18 | init?: EnvoyHttpRequestInit 19 | ): Options { 20 | if (!ctx) { 21 | return typeof params === "string" ? { url: params } : params; 22 | } 23 | 24 | let envoyContext: EnvoyContext; 25 | if (ctx instanceof EnvoyContext) { 26 | envoyContext = ctx; 27 | } else { 28 | envoyContext = new EnvoyContext(ctx); 29 | } 30 | 31 | const envoyParams = new EnvoyHttpRequestParams(envoyContext, init); 32 | 33 | let refinedParams: Options; 34 | if (typeof params === "string") { 35 | refinedParams = { url: params }; 36 | } else { 37 | refinedParams = { ...params }; 38 | } 39 | 40 | const refinedParamsWithUrl = refinedParams as OptionsWithUrl; 41 | const refinedParamsWithUri = refinedParams as OptionsWithUri; 42 | if (Object.prototype.hasOwnProperty.call(refinedParams, "url")) { 43 | refinedParamsWithUri.uri = refinedParamsWithUrl.url; 44 | delete refinedParamsWithUrl.url; 45 | } 46 | 47 | if (!refinedParamsWithUri.uri) { 48 | throw new Error("Cannot read url from params"); 49 | } 50 | 51 | if (typeof refinedParamsWithUri.uri === "string") { 52 | refinedParamsWithUri.uri = parseUrl(refinedParamsWithUri.uri); 53 | } 54 | 55 | const { protocol, host, path } = refinedParamsWithUri.uri; 56 | if (!protocol || !host || !path) { 57 | throw new Error("Cannot read the URL for envoy to fetch"); 58 | } 59 | 60 | const callDirectly = envoyParams.context.shouldCallWithoutEnvoy(host); 61 | 62 | if (protocol !== "http:" && protocol !== "https:") { 63 | throw new Error( 64 | `envoy request is designed only for http / https for now, current found: ${protocol}` 65 | ); 66 | } 67 | 68 | const oldHeaders: HttpHeader = {}; 69 | Object.assign(oldHeaders, refinedParamsWithUri.headers); 70 | 71 | refinedParamsWithUri.headers = { 72 | ...oldHeaders, 73 | ...envoyParams.assembleRequestHeaders(), 74 | host, 75 | }; 76 | 77 | if (!callDirectly) { 78 | refinedParamsWithUri.uri = `http://${envoyParams.context.envoyEgressAddr}:${envoyParams.context.envoyEgressPort}${path}`; 79 | } 80 | 81 | return refinedParams; 82 | } 83 | -------------------------------------------------------------------------------- /src/envoy-request-params.ts: -------------------------------------------------------------------------------- 1 | import EnvoyContext from "./envoy-context"; 2 | import { HttpHeader } from "./types"; 3 | 4 | /** 5 | * header of envoy max retries setting 6 | * @internal 7 | */ 8 | export const X_ENVOY_MAX_RETRIES = "x-envoy-max-retries"; 9 | /** 10 | * header of envoy request timeout 11 | * @internal 12 | */ 13 | export const X_ENVOY_UPSTREAM_RQ_TIMEOUT_MS = "x-envoy-upstream-rq-timeout-ms"; 14 | /** 15 | * header of envoy timeout per try 16 | * @internal 17 | */ 18 | export const X_ENVOY_UPSTREAM_RQ_PER_TRY_TIMEOUT_MS = "x-envoy-upstream-rq-per-try-timeout-ms"; 19 | 20 | /** 21 | * the Common signature of EnvoyRequestParams 22 | */ 23 | export default abstract class EnvoyRequestParams { 24 | /** 25 | * request context read from ingress traffic 26 | */ 27 | readonly context: EnvoyContext; 28 | 29 | /** 30 | * If a retry policy is in place, Envoy will default to retrying one time unless 31 | * explicitly specified. The number of retries can be explicitly set in the route 32 | * retry config or by using this header. If a retry policy is not configured and 33 | * x-envoy-retry-on or x-envoy-retry-grpc-on headers are not specified, Envoy will 34 | * not retry a failed request. 35 | * 36 | * A few notes on how Envoy does retries: 37 | * 38 | * - The route timeout (set via x-envoy-upstream-rq-timeout-ms or the route 39 | * configuration) includes all retries. Thus if the request timeout is set to 3s, 40 | * and the first request attempt takes 2.7s, the retry (including backoff) has .3s 41 | * to complete. This is by design to avoid an exponential retry/timeout explosion. 42 | * 43 | * - Envoy uses a fully jittered exponential backoff algorithm for retries with a 44 | * base time of 25ms. The first retry will be delayed randomly between 0-24ms, the 45 | * 2nd between 0-74ms, the 3rd between 0-174ms and so on. 46 | * 47 | * - If max retries is set both by header as well as in the route configuration, 48 | * the maximum value is taken when determining the max retries to use for the 49 | * request. 50 | */ 51 | readonly maxRetries: number; 52 | 53 | /** 54 | * Setting this header on egress requests will cause Envoy to override the route configuration. 55 | * The timeout must be specified in millisecond units. 56 | * Also see 57 | */ 58 | readonly timeout: number; 59 | 60 | /** 61 | * Setting this will cause Envoy to set a per try timeout on routed requests. This timeout must 62 | * be <= the global route timeout (see ) or it is ignored. 63 | * This allows a caller to set a tight per try timeout to allow for retries while maintaining a 64 | * reasonable overall timeout. 65 | */ 66 | readonly perTryTimeout: number; 67 | 68 | /** 69 | * extra customer headers to be set in the request 70 | */ 71 | readonly customHeaders: HttpHeader; 72 | 73 | constructor( 74 | context: EnvoyContext, 75 | maxRetries: number, 76 | timeout: number, 77 | perTryTimeout: number, 78 | headers: HttpHeader 79 | ) { 80 | this.context = context; 81 | this.maxRetries = maxRetries; 82 | this.timeout = timeout; 83 | this.perTryTimeout = perTryTimeout; 84 | this.customHeaders = headers; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChannelCredentials, 3 | Client, 4 | Metadata, 5 | requestCallback, 6 | ClientWritableStream, 7 | ClientReadableStream, 8 | ClientDuplexStream, 9 | Channel, 10 | } from "@grpc/grpc-js"; 11 | import EnvoyContext from "./envoy-context"; 12 | import { EnvoyGrpcRequestInit } from "./envoy-grpc-request-params"; 13 | 14 | /** 15 | * the HTTP header signature 16 | */ 17 | export interface HttpHeader { 18 | [s: string]: string | string[]; 19 | } 20 | 21 | /** 22 | * original constructor of gRPC 23 | */ 24 | export type ClientConstructor = new ( 25 | address: string, 26 | credentials: ChannelCredentials, 27 | options?: object 28 | ) => Client; 29 | 30 | /** 31 | * the API call 32 | * @param request the request body 33 | * @param options the option like timeout, retry, etc. 34 | */ 35 | export type RequestFunc = (request: any, options?: EnvoyGrpcRequestInit) => Promise; 36 | 37 | export type ClientStreamFunc = ( 38 | callback: requestCallback, 39 | options?: EnvoyGrpcRequestInit 40 | ) => ClientWritableStream; 41 | 42 | export type ServerStreamFunc = ( 43 | request: any, 44 | options?: EnvoyGrpcRequestInit 45 | ) => ClientReadableStream; 46 | 47 | export type BidiStreamFunc = (options?: EnvoyGrpcRequestInit) => ClientDuplexStream; 48 | 49 | export interface EnvoyClientFuncEnabled { 50 | /** 51 | * the API signature, dynamic attached for each gRPC request 52 | */ 53 | [methodName: string]: RequestFunc | ClientStreamFunc | ServerStreamFunc | BidiStreamFunc | any; 54 | } 55 | 56 | /** 57 | * the envoy client for gRPC 58 | */ 59 | export interface EnvoyClient extends Client, EnvoyClientFuncEnabled { 60 | /** 61 | * the original target remote address (hostname:port) 62 | */ 63 | readonly originalAddress: string; 64 | /** 65 | * the envoy context of this client 66 | */ 67 | readonly envoyContext: EnvoyContext; 68 | } 69 | 70 | /** 71 | * the wrapped class generator of EnvoyClient 72 | * create a new instance of Envoy client 73 | * @param address the address of remote target server 74 | * @param ctx the context, you can either tell me EnvoyContext, grpc.Metadata, or HttpHeader. 75 | * for the last two option, I will create EnvoyContext base of them. 76 | */ 77 | export type EnvoyClientConstructor = new ( 78 | address: string, 79 | ctx: EnvoyContext | Metadata | HttpHeader 80 | ) => T; 81 | 82 | export type ChannelFactoryOverride = ( 83 | target: string, 84 | credentials: ChannelCredentials, 85 | options: { [key: string]: string | number } 86 | ) => Channel; 87 | -------------------------------------------------------------------------------- /test/envoy-context-env.test.ts: -------------------------------------------------------------------------------- 1 | describe("Envoy context env", () => { 2 | it("should read envoy addr / port from ENV", () => { 3 | process.env.ENVOY_EGRESS_ADDR = "127.0.0.2"; 4 | process.env.ENVOY_EGRESS_PORT = "54321"; 5 | const envoyContext = require("../src/envoy-context"); 6 | const ctx = new envoyContext.default({}); 7 | expect(ctx.envoyEgressAddr).toBe("127.0.0.2"); 8 | expect(ctx.envoyEgressPort).toBe(54321); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/envoy-context-store.test.ts: -------------------------------------------------------------------------------- 1 | import EnvoyContext from "../src/envoy-context"; 2 | import store, { EliminateStore, NodeInfo } from "../src/envoy-context-store"; 3 | import { sleep } from "./lib/utils"; 4 | 5 | class Data { 6 | inputCtx?: EnvoyContext = undefined; 7 | fromSetTimeout?: EnvoyContext = undefined; 8 | fromPromise?: EnvoyContext = undefined; 9 | fromAsync?: EnvoyContext = undefined; 10 | } 11 | 12 | function timeoutExec(data: Data) { 13 | data.fromSetTimeout = store.get(); 14 | } 15 | 16 | function promiseExec(data: Data) { 17 | return Promise.resolve().then(() => { 18 | data.fromPromise = store.get(); 19 | }); 20 | } 21 | 22 | async function mainExecute(data: Data) { 23 | setTimeout(() => { 24 | timeoutExec(data); 25 | }, 10); 26 | await promiseExec(data); 27 | data.fromAsync = store.get(); 28 | } 29 | 30 | async function middlewareLogic0() { 31 | // empty 32 | } 33 | 34 | async function middlewareLogic1(data: Data) { 35 | if (data.inputCtx) { 36 | store.set(data.inputCtx); 37 | } 38 | } 39 | 40 | async function middleware(data: Data) { 41 | await middlewareLogic0(); 42 | await middlewareLogic1(data); 43 | } 44 | 45 | async function root(data: Data) { 46 | await middleware(data); 47 | await mainExecute(data); 48 | } 49 | 50 | describe("Envoy context store", () => { 51 | it("should works", async () => { 52 | const testData = [1, 2, 3].map(idx => { 53 | const data = new Data(); 54 | data.inputCtx = new EnvoyContext({ 55 | "x-request-id": `${idx}` 56 | }); 57 | return data; 58 | }); 59 | store.enable(); 60 | expect(store.isEnabled()).toBeTruthy(); 61 | for (const data of testData) { 62 | await root(data); 63 | } 64 | await sleep(50); 65 | store.disable(); 66 | testData.forEach(data => { 67 | expect(data.inputCtx).toBe(data.fromAsync); 68 | expect(data.inputCtx).toBe(data.fromPromise); 69 | expect(data.inputCtx).toBe(data.fromSetTimeout); 70 | }); 71 | expect(store.getStoreImpl().size()).toBe(0); 72 | expect(store.getStoreImpl().oldSize()).toBe(0); 73 | }); 74 | 75 | it("should trace error when set context if store is not enabled", () => { 76 | const originalTrace = console.trace; 77 | console.trace = jest.fn(); 78 | store.set(new EnvoyContext({})); 79 | expect(console.trace).toBeCalled(); 80 | console.trace = originalTrace; 81 | }); 82 | 83 | it("should trace error when get context if store is not enabled", () => { 84 | const originalTrace = console.trace; 85 | console.trace = jest.fn(); 86 | expect(store.get()).toBeUndefined(); 87 | expect(console.trace).toBeCalled(); 88 | console.trace = originalTrace; 89 | }); 90 | 91 | it("should trace error when enable / disable twice", () => { 92 | const originalTrace = console.trace; 93 | console.trace = jest.fn(); 94 | store.enable(); 95 | store.enable(); 96 | expect(console.trace).toBeCalled(); 97 | console.trace = jest.fn(); 98 | store.disable(); 99 | store.disable(); 100 | expect(console.trace).toBeCalled(); 101 | console.trace = originalTrace; 102 | }); 103 | 104 | it("Eliminate Store should works", () => { 105 | const es = new EliminateStore(); 106 | expect(es.get(1)).toBeUndefined(); 107 | expect(es.size()).toBe(0); 108 | expect(es.oldSize()).toBe(0); 109 | 110 | const info = new NodeInfo(1); 111 | es.set(1, info); 112 | expect(es.get(1)).toBe(info); 113 | expect(es.size()).toBe(1); 114 | expect(es.oldSize()).toBe(0); 115 | 116 | const oldTime = es.getLastEliminateTime(); 117 | for (let i = 0; i < 99999; i++) { 118 | // delay 119 | } 120 | es.eliminate(); 121 | expect(es.size()).toBe(0); 122 | expect(es.oldSize()).toBe(1); 123 | expect(es.getLastEliminateTime()).toBeGreaterThan(oldTime); 124 | 125 | const info2 = new NodeInfo(2); 126 | es.set(2, info2); 127 | expect(es.get(2)).toBe(info2); 128 | expect(es.size()).toBe(1); 129 | expect(es.oldSize()).toBe(1); 130 | 131 | expect(es.get(1)).toBe(info); 132 | expect(es.size()).toBe(2); 133 | expect(es.oldSize()).toBe(1); 134 | 135 | es.delete(1); 136 | expect(es.size()).toBe(1); 137 | expect(es.oldSize()).toBe(0); 138 | expect(es.get(1)).toBeUndefined(); 139 | expect(es.get(2)).toBe(info2); 140 | 141 | es.clear(); 142 | expect(es.size()).toBe(0); 143 | expect(es.oldSize()).toBe(0); 144 | 145 | expect(store.getDebugInfo()).toBeDefined(); 146 | }); 147 | 148 | it("should set eliminate interval works", () => { 149 | expect(store.getEliminateInterval()).toBe(300 * 1000); 150 | store.setEliminateInterval(1); 151 | expect(store.getEliminateInterval()).toBe(1); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /test/envoy-context.test.ts: -------------------------------------------------------------------------------- 1 | import EnvoyContext, { 2 | readMetaAsStringOrUndefined, 3 | readHeaderOrUndefined, 4 | assignHeader, 5 | refineManagedHostArray, 6 | } from "../src/envoy-context"; 7 | import { Metadata } from "@grpc/grpc-js"; 8 | import { HttpHeader } from "../src/types"; 9 | 10 | describe("Envoy context test", () => { 11 | it("should return the meta element if exist", () => { 12 | const meta = new Metadata(); 13 | meta.add("key", "value"); 14 | meta.add("key", "value2"); 15 | expect(readMetaAsStringOrUndefined(meta, "key")).toBe("value"); 16 | expect(readMetaAsStringOrUndefined(meta, "key-not-exist")).toBe(undefined); 17 | }); 18 | 19 | it("should return the header element if exist", () => { 20 | const header: HttpHeader = { 21 | key: "value", 22 | doubleKey: ["value1", "value2"], 23 | }; 24 | 25 | expect(readHeaderOrUndefined(header, "key")).toBe("value"); 26 | expect(readHeaderOrUndefined(header, "doubleKey")).toBe("value1"); 27 | expect(readHeaderOrUndefined(header, "not-ex-key")).toBe(undefined); 28 | }); 29 | 30 | it("should assign header correctly", () => { 31 | const header: HttpHeader = {}; 32 | assignHeader(header, "string", "value"); 33 | assignHeader(header, "number", 1); 34 | assignHeader(header, "zero", 0); 35 | assignHeader(header, "NaN", NaN); 36 | // tslint:disable-next-line:no-null-keyword 37 | assignHeader(header, "null", null); 38 | assignHeader(header, "undefined", undefined); 39 | expect(header.string).toBe("value"); 40 | expect(header.number).toBe("1"); 41 | expect(header.zero).toBe("0"); 42 | expect(Object.hasOwnProperty.call(header, "NaN")).toBeFalsy(); 43 | expect(Object.hasOwnProperty.call(header, "null")).toBeFalsy(); 44 | expect(Object.hasOwnProperty.call(header, "undefined")).toBeFalsy(); 45 | }); 46 | 47 | it("should use envoy default config if no information is found", () => { 48 | const ctx = new EnvoyContext({}); 49 | expect(ctx.envoyEgressAddr).toBe("127.0.0.1"); 50 | expect(ctx.envoyEgressPort).toBe(12345); 51 | }); 52 | 53 | it("should sanitize header correctly", () => { 54 | expect(refineManagedHostArray([])).toEqual([]); 55 | expect(refineManagedHostArray(["host:1234"])).toEqual(["host:1234"]); 56 | expect(refineManagedHostArray(["host:1234", "foo:1234"])).toEqual(["host:1234", "foo:1234"]); 57 | expect(refineManagedHostArray(["host:1234, foo:1234"])).toEqual(["host:1234", "foo:1234"]); 58 | expect(refineManagedHostArray(["host:1234,foo:1234"])).toEqual(["host:1234", "foo:1234"]); 59 | expect(refineManagedHostArray(["host:1234,foo:1234", "bar:1234"])).toEqual([ 60 | "host:1234", 61 | "foo:1234", 62 | "bar:1234", 63 | ]); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/envoy-fetch.test.ts: -------------------------------------------------------------------------------- 1 | import envoyFetch from "../src/envoy-fetch"; 2 | import EnvoyHttpRequestParams from "../src/envoy-http-request-params"; 3 | import EnvoyContext from "../src/envoy-context"; 4 | 5 | describe("envoy-fetch test", () => { 6 | it("should throw Error for invalid url", () => { 7 | expect.assertions(1); 8 | const param = new EnvoyHttpRequestParams(new EnvoyContext({})); 9 | envoyFetch(param, "invalid url").catch((e: Error) => { 10 | expect(e.message).toBe("Cannot read the URL for envoy to fetch"); 11 | }); 12 | }); 13 | it("should throw Error for ftp url", () => { 14 | expect.assertions(1); 15 | const param = new EnvoyHttpRequestParams(new EnvoyContext({})); 16 | envoyFetch(param, "ftp://foo/bar").catch((e: Error) => { 17 | expect(e.message).toBe( 18 | "envoy fetch is designed only for http / https for now, current found: ftp://foo/bar" 19 | ); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/envoy-grpc-request-params.test.ts: -------------------------------------------------------------------------------- 1 | import { httpHeader2Metadata } from "../src/envoy-grpc-request-params"; 2 | 3 | describe("http header to metadata", () => { 4 | it("should convert header to metadata correctly", () => { 5 | const meta = httpHeader2Metadata({ 6 | key: "value", 7 | doubleKey: ["value2", "value3"] 8 | }); 9 | 10 | const [value] = meta.get("key"); 11 | expect(value).toBe("value"); 12 | 13 | const [value2, value3] = meta.get("doubleKey"); 14 | expect(value2).toBe("value2"); 15 | expect(value3).toBe("value3"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/envoy-http-client.test.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import EnvoyHttpClient from "../src/envoy-http-client"; 3 | import EnvoyContext, { EnvoyContextInit } from "../src/envoy-context"; 4 | 5 | let TEST_PORT = 50000; 6 | 7 | describe("envoy http client status code test", () => { 8 | let contentType: string | undefined; 9 | let body: string | undefined; 10 | let statusCode: number; 11 | let testPort: number; 12 | 13 | const server = http.createServer((req, res) => { 14 | res.statusCode = statusCode; 15 | if (contentType) { 16 | res.setHeader("content-type", contentType); 17 | } 18 | if (body) { 19 | res.write(body); 20 | } 21 | res.end(); 22 | }); 23 | 24 | beforeEach(() => { 25 | contentType = undefined; 26 | body = undefined; 27 | statusCode = 200; 28 | testPort = TEST_PORT++; 29 | server.listen(testPort); 30 | }); 31 | 32 | afterEach(() => { 33 | server.close(); 34 | }); 35 | 36 | it("should process 404 correctly (json)", async () => { 37 | contentType = "application/json"; 38 | body = '{ "description": "NOT FOUND" }'; 39 | statusCode = 404; 40 | 41 | const client = new EnvoyHttpClient( 42 | new EnvoyContext({ 43 | meta: {}, 44 | envoyEgressPort: testPort, 45 | } as EnvoyContextInit) 46 | ); 47 | 48 | for (const method of ["get", "delete", "post", "patch", "put"]) { 49 | let notFoundHappened = false; 50 | let noException = false; 51 | 52 | try { 53 | await (client as any)[method]("http://foo/bar"); 54 | noException = true; 55 | } catch (e) { 56 | expect(e.$statusCode).toBe(404); 57 | expect(e.description).toBe("NOT FOUND"); 58 | notFoundHappened = true; 59 | } 60 | 61 | expect(notFoundHappened).toBeTruthy(); 62 | expect(noException).toBeFalsy(); 63 | } 64 | }); 65 | 66 | it("should process 404 correctly (text/plain)", async () => { 67 | contentType = "text/plain"; 68 | body = "NOT FOUND"; 69 | statusCode = 404; 70 | 71 | const client = new EnvoyHttpClient( 72 | new EnvoyContext({ 73 | meta: {}, 74 | envoyEgressPort: testPort, 75 | } as EnvoyContextInit) 76 | ); 77 | 78 | let notFoundHappened = false; 79 | let noException = false; 80 | 81 | try { 82 | await client.get("http://foo/bar"); 83 | noException = true; 84 | } catch (e) { 85 | expect(e.$statusCode).toBe(404); 86 | expect(e.message).toBe("NOT FOUND"); 87 | notFoundHappened = true; 88 | } 89 | 90 | expect(notFoundHappened).toBeTruthy(); 91 | expect(noException).toBeFalsy(); 92 | }); 93 | 94 | it("should process 204 correctly", async () => { 95 | statusCode = 204; 96 | 97 | const client = new EnvoyHttpClient( 98 | new EnvoyContext({ 99 | meta: {}, 100 | envoyEgressPort: testPort, 101 | } as EnvoyContextInit) 102 | ); 103 | const response = await client.get("http://foo/bar"); 104 | expect(response).toBe(undefined); 105 | }); 106 | 107 | it("should process neithor json nor text correctly (no content-type)", async () => { 108 | statusCode = 200; 109 | body = "no content type is provided"; 110 | 111 | const client = new EnvoyHttpClient( 112 | new EnvoyContext({ 113 | meta: {}, 114 | envoyEgressPort: testPort, 115 | } as EnvoyContextInit) 116 | ); 117 | 118 | let notFoundHappened = false; 119 | let noException = false; 120 | 121 | try { 122 | await client.get("http://foo/bar"); 123 | noException = true; 124 | } catch (e) { 125 | expect(e.$statusCode).toBe(200); 126 | expect(e.message).toBe("Unexpected content type: null, http status: 200"); 127 | notFoundHappened = true; 128 | } 129 | 130 | expect(notFoundHappened).toBeTruthy(); 131 | expect(noException).toBeFalsy(); 132 | }); 133 | 134 | it("should process neither json nor text correctly (application/bin)", async () => { 135 | statusCode = 200; 136 | contentType = "application/bin"; 137 | body = "i pretend i am bin"; 138 | 139 | const client = new EnvoyHttpClient( 140 | new EnvoyContext({ 141 | meta: {}, 142 | envoyEgressPort: testPort, 143 | } as EnvoyContextInit) 144 | ); 145 | 146 | let notFoundHappened = false; 147 | let noException = false; 148 | 149 | try { 150 | await client.get("http://foo/bar"); 151 | noException = true; 152 | } catch (e) { 153 | expect(e.$statusCode).toBe(200); 154 | expect(e.message).toBe("Unexpected content type: application/bin, http status: 200"); 155 | notFoundHappened = true; 156 | } 157 | 158 | expect(notFoundHappened).toBeTruthy(); 159 | expect(noException).toBeFalsy(); 160 | }); 161 | 162 | it("should process text correctly", async () => { 163 | statusCode = 200; 164 | contentType = "text/plain"; 165 | body = "hello world!"; 166 | 167 | const client = new EnvoyHttpClient( 168 | new EnvoyContext({ 169 | meta: {}, 170 | envoyEgressPort: testPort, 171 | } as EnvoyContextInit) 172 | ); 173 | 174 | const text = await client.get("http://foo/bar"); 175 | expect(text).toBe(body); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /test/envoy-request-params-refiner.test.ts: -------------------------------------------------------------------------------- 1 | import { envoyRequestParamsRefiner } from "../src/envoy-node"; 2 | import EnvoyContext, { EnvoyContextInit } from "../src/envoy-context"; 3 | import { Options, OptionsWithUrl, OptionsWithUri } from "request"; 4 | import { parse, Url } from "url"; 5 | 6 | describe("Envoy request params refiner test", () => { 7 | it("should throw exception for invalid params", () => { 8 | expect(() => { 9 | envoyRequestParamsRefiner("invalid url", {}); 10 | }).toThrow(); 11 | expect(() => { 12 | envoyRequestParamsRefiner( 13 | { 14 | /* no url */ 15 | url: "" 16 | }, 17 | {} 18 | ); 19 | }).toThrow(); 20 | expect(() => { 21 | envoyRequestParamsRefiner({ url: "invalid url" }, {}); 22 | }).toThrow(); 23 | expect(() => { 24 | envoyRequestParamsRefiner("ftp://foo.bar/path", {}); 25 | }).toThrow(); 26 | expect(() => { 27 | envoyRequestParamsRefiner({ url: "ftp://foo.bar/path" }, new EnvoyContext({})); 28 | }).toThrow(); 29 | }); 30 | 31 | it("should refine the params (string url)", () => { 32 | const { uri, headers } = envoyRequestParamsRefiner("http://foo.bar:54321/path", { 33 | "x-ot-span-context": "aaaaaaaa:bbbbbbbb:cccccccc" 34 | }) as OptionsWithUri; 35 | 36 | if (headers === undefined) { 37 | throw new Error(); 38 | } 39 | 40 | expect(uri).toBe("http://127.0.0.1:12345/path"); 41 | expect(headers.host).toBe("foo.bar:54321"); 42 | expect(headers["x-ot-span-context"]).toBe("aaaaaaaa:bbbbbbbb:cccccccc"); 43 | }); 44 | 45 | it("should refine the params (url field)", () => { 46 | const { uri, headers } = envoyRequestParamsRefiner( 47 | { url: "http://foo.bar:54321/path" }, 48 | { 49 | "x-ot-span-context": "aaaaaaaa:bbbbbbbb:cccccccc" 50 | } 51 | ) as OptionsWithUri; 52 | 53 | if (headers === undefined) { 54 | throw new Error(); 55 | } 56 | 57 | expect(uri).toBe("http://127.0.0.1:12345/path"); 58 | expect(headers.host).toBe("foo.bar:54321"); 59 | expect(headers["x-ot-span-context"]).toBe("aaaaaaaa:bbbbbbbb:cccccccc"); 60 | }); 61 | 62 | it("should refine the params (string uri field)", () => { 63 | const { uri, headers } = envoyRequestParamsRefiner( 64 | { uri: "http://foo.bar:54321/path" }, 65 | { 66 | "x-ot-span-context": "aaaaaaaa:bbbbbbbb:cccccccc" 67 | } 68 | ) as OptionsWithUri; 69 | 70 | if (headers === undefined) { 71 | throw new Error(); 72 | } 73 | 74 | expect(uri).toBe("http://127.0.0.1:12345/path"); 75 | expect(headers.host).toBe("foo.bar:54321"); 76 | expect(headers["x-ot-span-context"]).toBe("aaaaaaaa:bbbbbbbb:cccccccc"); 77 | }); 78 | 79 | it("should refine the params (object uri field)", () => { 80 | const { uri, headers } = envoyRequestParamsRefiner( 81 | { uri: parse("http://foo.bar:54321/path") }, 82 | { 83 | "x-ot-span-context": "aaaaaaaa:bbbbbbbb:cccccccc" 84 | } 85 | ) as OptionsWithUri; 86 | 87 | if (headers === undefined) { 88 | throw new Error(); 89 | } 90 | 91 | expect(uri).toBe("http://127.0.0.1:12345/path"); 92 | expect(headers.host).toBe("foo.bar:54321"); 93 | expect(headers["x-ot-span-context"]).toBe("aaaaaaaa:bbbbbbbb:cccccccc"); 94 | }); 95 | 96 | it("should not change the url in direct mode", () => { 97 | const init: EnvoyContextInit = { 98 | meta: { 99 | "x-ot-span-context": "aaaaaaaa:bbbbbbbb:cccccccc" 100 | }, 101 | directMode: true 102 | }; 103 | const { uri, headers } = envoyRequestParamsRefiner( 104 | "http://foo.bar:54321/path", 105 | new EnvoyContext(init) 106 | ) as OptionsWithUri; 107 | 108 | if (headers === undefined) { 109 | throw new Error(); 110 | } 111 | 112 | expect((uri as Url).href).toBe("http://foo.bar:54321/path"); 113 | expect(headers.host).toBe("foo.bar:54321"); 114 | expect(headers["x-ot-span-context"]).toBe("aaaaaaaa:bbbbbbbb:cccccccc"); 115 | }); 116 | 117 | it("should not change the url in direct mode (base on managed host)", () => { 118 | const init: EnvoyContextInit = { 119 | meta: { 120 | "x-ot-span-context": "aaaaaaaa:bbbbbbbb:cccccccc" 121 | }, 122 | envoyManagedHosts: new Set(["this.is.not.foo.bar:54321"]) 123 | }; 124 | const { uri, headers } = envoyRequestParamsRefiner( 125 | "http://foo.bar:54321/path", 126 | new EnvoyContext(init) 127 | ) as OptionsWithUri; 128 | 129 | if (headers === undefined) { 130 | throw new Error(); 131 | } 132 | 133 | expect((uri as Url).href).toBe("http://foo.bar:54321/path"); 134 | expect(headers.host).toBe("foo.bar:54321"); 135 | expect(headers["x-ot-span-context"]).toBe("aaaaaaaa:bbbbbbbb:cccccccc"); 136 | }); 137 | 138 | it("should return options directly if no context is supplied", () => { 139 | const url = "http://foo.service:12345/path"; 140 | expect(envoyRequestParamsRefiner(url)).toEqual({ url }); 141 | expect(envoyRequestParamsRefiner(url, undefined)).toEqual({ url }); 142 | expect(envoyRequestParamsRefiner({ url })).toEqual({ url }); 143 | expect(envoyRequestParamsRefiner({ url }, undefined)).toEqual({ url }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/grpc-bidi-stream.test.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from "@grpc/grpc-js"; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { ServerUnaryCall, sendUnaryData, ServiceError, ServerDuplexStream } from "@grpc/grpc-js"; 4 | 5 | import GrpcTestServer, { Ping, PingEnvoyClient } from "./lib/grpc-test-server"; 6 | import { sleep } from "./lib/utils"; 7 | import { RequestFunc, EnvoyClient } from "../src/types"; 8 | import { GrpcRetryOn, EnvoyContext } from "../src/envoy-node"; 9 | 10 | describe("GRPC bidi stream Test", () => { 11 | it("should propagate the tracing header correctly", async () => { 12 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 13 | let requestId: string | undefined; 14 | let traceId: string | undefined; 15 | let innerParentId: string | undefined; 16 | const server = new (class extends GrpcTestServer { 17 | constructor() { 18 | super(30, false); 19 | } 20 | 21 | async wrapper(call: ServerUnaryCall): Promise { 22 | const innerClient = new PingEnvoyClient( 23 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 24 | new EnvoyContext(call.metadata) 25 | ); 26 | const ctx = innerClient.envoyContext; 27 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 28 | requestId = ctx.requestId; 29 | traceId = ctx.traceId; 30 | innerParentId = ctx.spanId; 31 | await new Promise((resolve, reject) => { 32 | const stream = innerClient.bidiStream(); 33 | stream.write({ message: call.request.message }); 34 | stream.on("error", (error) => { 35 | reject(error); 36 | }); 37 | stream.on("data", (data: any) => { 38 | try { 39 | expect(data.message).toBe("pong"); 40 | } catch (e) { 41 | reject(e); 42 | } 43 | }); 44 | stream.on("end", () => { 45 | resolve(); 46 | }); 47 | stream.end(); 48 | }); 49 | return { message: "pong" }; 50 | } 51 | 52 | bidiStream(call: ServerDuplexStream): void { 53 | const { metadata }: { metadata: grpc.Metadata } = call as any; // TODO gRPC library' typing is incorrect 54 | const ctx = new EnvoyContext(metadata); 55 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 56 | expect(ctx.requestId).toBe(requestId); 57 | expect(ctx.traceId).toBe(traceId); 58 | expect(ctx.parentSpanId).toBe(innerParentId); 59 | call.write({ message: "pong" }); 60 | call.on("data", (data: any) => { 61 | expect(data.message).toBe("ping"); 62 | }); 63 | call.on("end", () => { 64 | call.end(); 65 | }); 66 | } 67 | })(); 68 | 69 | server.bind(async () => { 70 | await server.start(); 71 | 72 | try { 73 | const clientMetadata = new grpc.Metadata(); 74 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 75 | const client = new Ping( 76 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 77 | grpc.credentials.createInsecure() 78 | ); 79 | const response = await new Promise((resolve, reject) => { 80 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 81 | if (err) { 82 | reject(err); 83 | return; 84 | } 85 | resolve(response); 86 | }); 87 | }); 88 | } finally { 89 | await server.stop(); 90 | } 91 | }); 92 | }); 93 | 94 | // NOTE: Timeout is not testable, skip 95 | // I cannot test it as network timeout here, but operation time is hard to simulate 96 | 97 | // NOTE: retry is not testable, skip 98 | // I cannot test it as node gRPC does not have a method to let me throw gRPC error 99 | }); 100 | -------------------------------------------------------------------------------- /test/grpc-client-stream.test.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from "@grpc/grpc-js"; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { ServerUnaryCall, sendUnaryData, ServiceError, ServerReadableStream } from "@grpc/grpc-js"; 4 | 5 | import GrpcTestServer, { Ping, PingEnvoyClient } from "./lib/grpc-test-server"; 6 | import { RequestFunc, EnvoyClient } from "../src/types"; 7 | import { GrpcRetryOn, EnvoyContext } from "../src/envoy-node"; 8 | 9 | describe("GRPC client stream Test", () => { 10 | it("should propagate the tracing header correctly", async () => { 11 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 12 | let requestId: string | undefined; 13 | let traceId: string | undefined; 14 | let innerParentId: string | undefined; 15 | 16 | const server = new (class extends GrpcTestServer { 17 | constructor() { 18 | super(40); 19 | } 20 | 21 | async wrapper(call: ServerUnaryCall): Promise { 22 | const innerClient = new PingEnvoyClient( 23 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 24 | new EnvoyContext(call.metadata) 25 | ); 26 | const ctx = innerClient.envoyContext; 27 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 28 | requestId = ctx.requestId; 29 | traceId = ctx.traceId; 30 | innerParentId = ctx.spanId; 31 | await new Promise((resolve, reject) => { 32 | const stream = innerClient.clientStream((err, response) => { 33 | if (err) { 34 | reject(err); 35 | } 36 | expect(response.message).toBe("clientStream:pong"); 37 | resolve(); 38 | }); 39 | stream.write({ message: call.request.message }); 40 | stream.end(); 41 | }); 42 | return { message: "pong" }; 43 | } 44 | 45 | clientStream(call: ServerReadableStream, callback: sendUnaryData): void { 46 | const ctx = new EnvoyContext(call.metadata); 47 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 48 | expect(ctx.requestId).toBe(requestId); 49 | expect(ctx.traceId).toBe(traceId); 50 | expect(ctx.parentSpanId).toBe(innerParentId); 51 | call.on("data", (data: any) => { 52 | expect(data.message).toBe("ping"); 53 | }); 54 | call.on("error", (err) => { 55 | callback(err, undefined); 56 | }); 57 | call.on("end", () => { 58 | // tslint:disable-next-line:no-null-keyword 59 | callback(null, { message: "clientStream:pong" }); 60 | }); 61 | } 62 | })(); 63 | 64 | server.bind(async () => { 65 | await server.start(); 66 | 67 | try { 68 | const clientMetadata = new grpc.Metadata(); 69 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 70 | const client = new Ping( 71 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 72 | grpc.credentials.createInsecure() 73 | ); 74 | const response = await new Promise((resolve, reject) => { 75 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 76 | if (err) { 77 | reject(err); 78 | return; 79 | } 80 | resolve(response); 81 | }); 82 | }); 83 | } finally { 84 | await server.stop(); 85 | } 86 | }); 87 | }); 88 | 89 | // NOTE: Timeout is not testable, skip 90 | 91 | it("should handle retry correctly", async () => { 92 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 93 | let innerCalledCount = 0; 94 | 95 | const server = new (class extends GrpcTestServer { 96 | constructor() { 97 | super(41); 98 | } 99 | 100 | async wrapper(call: ServerUnaryCall): Promise { 101 | const innerClient = new PingEnvoyClient( 102 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 103 | call.metadata 104 | ); 105 | 106 | await new Promise((resolve, reject) => { 107 | const stream = innerClient.clientStream( 108 | (err, response) => { 109 | if (err) { 110 | return reject(err); 111 | } 112 | expect(response.message).toBe("clientStream:pong"); 113 | resolve(); 114 | }, 115 | { 116 | maxRetries: 2, 117 | retryOn: [GrpcRetryOn.DEADLINE_EXCEEDED], 118 | } 119 | ); 120 | stream.write({ message: call.request.message }); 121 | stream.end(); 122 | }); 123 | 124 | return { message: "pong" }; 125 | } 126 | 127 | clientStream(call: ServerReadableStream, callback: sendUnaryData): void { 128 | call.on("data", (data: any) => { 129 | expect(data.message).toBe("ping"); 130 | }); 131 | innerCalledCount++; 132 | if (innerCalledCount < 2) { 133 | const error = new Error("DEADLINE_EXCEEDED") as ServiceError; 134 | error.code = grpc.status.DEADLINE_EXCEEDED; 135 | error.metadata = call.metadata; 136 | callback(error, undefined); 137 | return; 138 | } 139 | call.on("error", (err) => { 140 | callback(err, undefined); 141 | }); 142 | call.on("end", () => { 143 | // tslint:disable-next-line:no-null-keyword 144 | callback(null, { message: "clientStream:pong" }); 145 | }); 146 | } 147 | })(); 148 | 149 | server.bind(async () => { 150 | await server.start(); 151 | 152 | try { 153 | const clientMetadata = new grpc.Metadata(); 154 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 155 | const client = new Ping( 156 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 157 | grpc.credentials.createInsecure() 158 | ); 159 | const response = await new Promise((resolve, reject) => { 160 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 161 | if (err) { 162 | reject(err); 163 | return; 164 | } 165 | resolve(response); 166 | }); 167 | }); 168 | expect(innerCalledCount).toBe(2); 169 | expect(response.message).toBe("pong"); 170 | } finally { 171 | await server.stop(); 172 | } 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /test/grpc-server-stream.test.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from "@grpc/grpc-js"; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { ServerUnaryCall, sendUnaryData, ServiceError, ServerWritableStream } from "@grpc/grpc-js"; 4 | 5 | import GrpcTestServer, { Ping, PingEnvoyClient } from "./lib/grpc-test-server"; 6 | import { RequestFunc, EnvoyClient } from "../src/types"; 7 | import { GrpcRetryOn, EnvoyContext } from "../src/envoy-node"; 8 | 9 | describe("GRPC server stream Test", () => { 10 | it("should propagate the tracing header correctly", async () => { 11 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 12 | let requestId: string | undefined; 13 | let traceId: string | undefined; 14 | let innerParentId: string | undefined; 15 | 16 | const server = new (class extends GrpcTestServer { 17 | constructor() { 18 | super(50); 19 | } 20 | 21 | async wrapper(call: ServerUnaryCall): Promise { 22 | const innerClient = new PingEnvoyClient( 23 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 24 | new EnvoyContext(call.metadata) 25 | ); 26 | const ctx = innerClient.envoyContext; 27 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 28 | requestId = ctx.requestId; 29 | traceId = ctx.traceId; 30 | innerParentId = ctx.spanId; 31 | await new Promise((resolve, reject) => { 32 | const stream = innerClient.serverStream({ message: call.request.message }); 33 | stream.on("error", (error) => { 34 | reject(error); 35 | }); 36 | stream.on("data", (data: any) => { 37 | try { 38 | expect(data.message).toBe("pong"); 39 | } catch (e) { 40 | reject(e); 41 | } 42 | }); 43 | stream.on("end", () => { 44 | resolve(); 45 | }); 46 | }); 47 | return { message: "pong" }; 48 | } 49 | 50 | serverStream(call: ServerWritableStream): void { 51 | const ctx = new EnvoyContext(call.metadata); 52 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 53 | expect(ctx.requestId).toBe(requestId); 54 | expect(ctx.traceId).toBe(traceId); 55 | expect(ctx.parentSpanId).toBe(innerParentId); 56 | expect(call.request.message).toBe("ping"); 57 | call.write({ message: "pong" }); 58 | call.end(); 59 | } 60 | })(); 61 | 62 | server.bind(async () => { 63 | await server.start(); 64 | 65 | try { 66 | const clientMetadata = new grpc.Metadata(); 67 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 68 | const client = new Ping( 69 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 70 | grpc.credentials.createInsecure() 71 | ); 72 | const response = await new Promise((resolve, reject) => { 73 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 74 | if (err) { 75 | reject(err); 76 | return; 77 | } 78 | resolve(response); 79 | }); 80 | }); 81 | } finally { 82 | await server.stop(); 83 | } 84 | }); 85 | }); 86 | 87 | // NOTE: Timeout is not testable, skip 88 | // I cannot test it as network timeout here, but operation time is hard to simulate 89 | 90 | // NOTE: retry is not testable, skip 91 | // I cannot test it as node gRPC does not have a method to let me throw gRPC error 92 | }); 93 | -------------------------------------------------------------------------------- /test/grpc.test.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from "@grpc/grpc-js"; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { ServerUnaryCall, sendUnaryData, ServiceError } from "@grpc/grpc-js"; 4 | import { sleep } from "./lib/utils"; 5 | import GrpcTestServer, { Ping, PingEnvoyClient } from "./lib/grpc-test-server"; 6 | import { RequestFunc, EnvoyClient } from "../src/types"; 7 | import { GrpcRetryOn, EnvoyContext } from "../src/envoy-node"; 8 | import { EnvoyContextInit } from "../src/envoy-context"; 9 | 10 | describe("GRPC Test", () => { 11 | it("should propagate the tracing header correctly", async () => { 12 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 13 | let requestId: string | undefined; 14 | let traceId: string | undefined; 15 | let innerParentId: string | undefined; 16 | 17 | const server = new (class extends GrpcTestServer { 18 | constructor() { 19 | super(0); 20 | } 21 | 22 | async wrapper(call: ServerUnaryCall): Promise { 23 | const innerClient = new PingEnvoyClient( 24 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 25 | call.metadata 26 | ); 27 | const ctx = innerClient.envoyContext; 28 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 29 | requestId = ctx.requestId; 30 | traceId = ctx.traceId; 31 | innerParentId = ctx.spanId; 32 | return innerClient.inner({ message: call.request.message }); 33 | } 34 | 35 | async inner(call: ServerUnaryCall): Promise { 36 | const ctx = new EnvoyContext(call.metadata); 37 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 38 | expect(ctx.requestId).toBe(requestId); 39 | expect(ctx.traceId).toBe(traceId); 40 | expect(ctx.parentSpanId).toBe(innerParentId); 41 | return { message: "pong" }; 42 | } 43 | })(); 44 | 45 | server.bind(async () => { 46 | await server.start(); 47 | 48 | try { 49 | const clientMetadata = new grpc.Metadata(); 50 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 51 | const client = new Ping( 52 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 53 | grpc.credentials.createInsecure() 54 | ); 55 | const response = await new Promise((resolve, reject) => { 56 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 57 | if (err) { 58 | reject(err); 59 | return; 60 | } 61 | resolve(response); 62 | }); 63 | }); 64 | } finally { 65 | await server.stop(); 66 | } 67 | }); 68 | }); 69 | 70 | it("should handle timeout correctly", async () => { 71 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 72 | const WRAPPER_SLEEP_TIME = 100; 73 | let innerCalledCount = 0; 74 | 75 | const server = new (class extends GrpcTestServer { 76 | constructor() { 77 | super(1); 78 | } 79 | 80 | async wrapper(call: ServerUnaryCall): Promise { 81 | const innerClient = new PingEnvoyClient( 82 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 83 | call.metadata 84 | ); 85 | 86 | const startTime = Date.now(); 87 | 88 | let noException = false; 89 | 90 | try { 91 | await innerClient.inner({ message: call.request.message }, { timeout: 10 }); 92 | noException = true; 93 | } catch (e) { 94 | expect(e.message).toBe("14 UNAVAILABLE: upstream request timeout"); 95 | } 96 | 97 | expect(noException).toBeFalsy(); 98 | 99 | const endTime = Date.now(); 100 | 101 | expect(endTime - startTime).toBeLessThan(WRAPPER_SLEEP_TIME); 102 | 103 | return { message: "" }; 104 | } 105 | 106 | async inner(call: ServerUnaryCall): Promise { 107 | const ctx = new EnvoyContext(call.metadata); 108 | innerCalledCount++; 109 | if (innerCalledCount < 2) { 110 | await sleep(WRAPPER_SLEEP_TIME); 111 | } 112 | return { message: "pong" }; 113 | } 114 | })(); 115 | 116 | server.bind(async () => { 117 | await server.start(); 118 | 119 | try { 120 | const clientMetadata = new grpc.Metadata(); 121 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 122 | const client = new Ping( 123 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 124 | grpc.credentials.createInsecure() 125 | ); 126 | const response = await new Promise((resolve, reject) => { 127 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 128 | if (err) { 129 | reject(err); 130 | return; 131 | } 132 | resolve(response); 133 | }); 134 | }); 135 | } finally { 136 | await server.stop(); 137 | } 138 | }); 139 | }); 140 | 141 | it("should handle retry correctly", async () => { 142 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 143 | let innerCalledCount = 0; 144 | 145 | const server = new (class extends GrpcTestServer { 146 | constructor() { 147 | super(2); 148 | } 149 | 150 | async wrapper(call: ServerUnaryCall): Promise { 151 | const innerClient = new PingEnvoyClient( 152 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 153 | call.metadata 154 | ); 155 | 156 | return innerClient.inner( 157 | { message: call.request.message }, 158 | { 159 | maxRetries: 2, 160 | retryOn: [GrpcRetryOn.DEADLINE_EXCEEDED], 161 | } 162 | ); 163 | } 164 | 165 | async inner(call: ServerUnaryCall): Promise { 166 | const ctx = new EnvoyContext(call.metadata); 167 | innerCalledCount++; 168 | if (innerCalledCount < 2) { 169 | const error = new Error("DEADLINE_EXCEEDED") as ServiceError; 170 | error.code = grpc.status.DEADLINE_EXCEEDED; 171 | error.metadata = call.metadata; 172 | throw error; 173 | } 174 | return { message: `pong ${innerCalledCount}` }; 175 | } 176 | })(); 177 | 178 | server.bind(async () => { 179 | await server.start(); 180 | 181 | try { 182 | const clientMetadata = new grpc.Metadata(); 183 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 184 | const client = new Ping( 185 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 186 | grpc.credentials.createInsecure() 187 | ); 188 | const response = await new Promise((resolve, reject) => { 189 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 190 | if (err) { 191 | reject(err); 192 | return; 193 | } 194 | resolve(response); 195 | }); 196 | }); 197 | expect(innerCalledCount).toBe(2); 198 | expect(response.message).toBe("pong 2"); 199 | } finally { 200 | await server.stop(); 201 | } 202 | }); 203 | }); 204 | 205 | it("should handle per retry timeout correctly", async () => { 206 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 207 | let innerCalledCount = 0; 208 | 209 | const server = new (class extends GrpcTestServer { 210 | constructor() { 211 | super(3); 212 | } 213 | 214 | async wrapper(call: ServerUnaryCall): Promise { 215 | const innerClient = new PingEnvoyClient( 216 | `${GrpcTestServer.domainName}:${this.envoyIngressPort}`, 217 | call.metadata 218 | ) as PingEnvoyClient; 219 | 220 | let errorHappened = false; 221 | 222 | try { 223 | await innerClient.inner( 224 | { message: call.request.message }, 225 | { 226 | maxRetries: 3, 227 | retryOn: [GrpcRetryOn.DEADLINE_EXCEEDED], 228 | perTryTimeout: 100, 229 | } 230 | ); 231 | } catch (e) { 232 | errorHappened = true; 233 | expect(e.message).toBe("14 UNAVAILABLE: upstream request timeout"); 234 | } 235 | expect(errorHappened).toBeTruthy(); 236 | expect(innerCalledCount).toBe(2); 237 | return { message: "" }; 238 | } 239 | 240 | async inner(call: ServerUnaryCall): Promise { 241 | const ctx = new EnvoyContext(call.metadata); 242 | innerCalledCount++; 243 | if (innerCalledCount === 2) { 244 | await sleep(110); 245 | } 246 | if (innerCalledCount < 3) { 247 | const error = new Error("DEADLINE_EXCEEDED") as ServiceError; 248 | error.code = grpc.status.DEADLINE_EXCEEDED; 249 | error.metadata = call.metadata; 250 | throw error; 251 | } 252 | return { message: `pong ${innerCalledCount}` }; 253 | } 254 | })(); 255 | 256 | server.bind(async () => { 257 | await server.start(); 258 | 259 | try { 260 | const clientMetadata = new grpc.Metadata(); 261 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 262 | const client = new Ping( 263 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 264 | grpc.credentials.createInsecure() 265 | ); 266 | await new Promise((resolve, reject) => { 267 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 268 | if (err) { 269 | reject(err); 270 | return; 271 | } 272 | resolve(response); 273 | }); 274 | }); 275 | } finally { 276 | await server.stop(); 277 | } 278 | }); 279 | }); 280 | 281 | it("should propagate the tracing header directly in direct mode", async () => { 282 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 283 | let requestId: string | undefined; 284 | let traceId: string | undefined; 285 | let spanId: string | undefined; 286 | 287 | const server = new (class extends GrpcTestServer { 288 | constructor() { 289 | super(4); 290 | } 291 | 292 | async wrapper(call: ServerUnaryCall): Promise { 293 | const init: EnvoyContextInit = { 294 | meta: call.metadata, 295 | directMode: true, 296 | }; 297 | const innerClient = new PingEnvoyClient( 298 | `${GrpcTestServer.bindHost}:${this.envoyIngressPort}`, 299 | new EnvoyContext(init) 300 | ); 301 | const ctx = innerClient.envoyContext; 302 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 303 | requestId = ctx.requestId; 304 | traceId = ctx.traceId; 305 | spanId = ctx.spanId; 306 | return innerClient.inner({ message: call.request.message }); 307 | } 308 | 309 | async inner(call: ServerUnaryCall): Promise { 310 | const ctx = new EnvoyContext(call.metadata); 311 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 312 | expect(ctx.requestId).toBe(requestId); 313 | expect(ctx.traceId).toBe(traceId); 314 | expect(ctx.spanId).toBe(spanId); 315 | return { message: "pong" }; 316 | } 317 | })(); 318 | 319 | server.bind(async () => { 320 | await server.start(); 321 | 322 | try { 323 | const clientMetadata = new grpc.Metadata(); 324 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 325 | const client = new Ping( 326 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 327 | grpc.credentials.createInsecure() 328 | ); 329 | const response = await new Promise((resolve, reject) => { 330 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 331 | if (err) { 332 | reject(err); 333 | return; 334 | } 335 | resolve(response); 336 | }); 337 | }); 338 | } finally { 339 | await server.stop(); 340 | } 341 | }); 342 | }); 343 | 344 | it("should propagate the tracing header directly if the host is not in managed host", async () => { 345 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 346 | let requestId: string | undefined; 347 | let traceId: string | undefined; 348 | let spanId: string | undefined; 349 | 350 | const server = new (class extends GrpcTestServer { 351 | constructor() { 352 | super(5, true); 353 | } 354 | 355 | async wrapper(call: ServerUnaryCall): Promise { 356 | const init: EnvoyContextInit = { 357 | meta: call.metadata, 358 | }; 359 | const innerClient = new PingEnvoyClient( 360 | `${GrpcTestServer.bindHost}:${this.envoyIngressPort}`, 361 | new EnvoyContext(init) 362 | ); 363 | const ctx = innerClient.envoyContext; 364 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 365 | requestId = ctx.requestId; 366 | traceId = ctx.traceId; 367 | spanId = ctx.spanId; 368 | return innerClient.inner({ message: call.request.message }); 369 | } 370 | 371 | async inner(call: ServerUnaryCall): Promise { 372 | const ctx = new EnvoyContext(call.metadata); 373 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 374 | expect(ctx.requestId).toBe(requestId); 375 | expect(ctx.traceId).toBe(traceId); 376 | expect(ctx.spanId).toBe(spanId); 377 | return { message: "pong" }; 378 | } 379 | })(); 380 | 381 | server.bind(async () => { 382 | await server.start(); 383 | 384 | try { 385 | const clientMetadata = new grpc.Metadata(); 386 | clientMetadata.add("x-client-trace-id", CLIENT_TRACE_ID); 387 | const client = new Ping( 388 | `${GrpcTestServer.bindHost}:${server.envoyIngressPort}`, 389 | grpc.credentials.createInsecure() 390 | ); 391 | const response = await new Promise((resolve, reject) => { 392 | client.wrapper({ message: "ping" }, clientMetadata, (err: ServiceError, response: any) => { 393 | if (err) { 394 | reject(err); 395 | return; 396 | } 397 | resolve(response); 398 | }); 399 | }); 400 | } finally { 401 | await server.stop(); 402 | } 403 | }); 404 | }); 405 | }); 406 | -------------------------------------------------------------------------------- /test/http.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import HttpTestServer, { Request } from "./lib/http-test-server"; 3 | import { HttpHeader, RequestFunc } from "../src/types"; 4 | import { sleep } from "./lib/utils"; 5 | import simplePost from "./lib/simple-post"; 6 | import { 7 | EnvoyContext, 8 | HttpRetryOn, 9 | EnvoyHttpRequestParams, 10 | EnvoyHttpClient, 11 | envoyFetch 12 | } from "../src/envoy-node"; 13 | import { EnvoyContextInit } from "../src/envoy-context"; 14 | 15 | describe("HTTP Test", () => { 16 | it("should propagate the tracing header correctly", async () => { 17 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 18 | let requestId: string | undefined; 19 | let traceId: string | undefined; 20 | let innerParentId: string | undefined; 21 | 22 | const server = new class extends HttpTestServer { 23 | constructor() { 24 | super(10); 25 | } 26 | 27 | async wrapper(request: Request): Promise { 28 | const client = new EnvoyHttpClient(request.headers as HttpHeader); 29 | // asserts 30 | const ctx = client.envoyContext; 31 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 32 | requestId = ctx.requestId; 33 | traceId = ctx.traceId; 34 | innerParentId = ctx.spanId; 35 | // send request to inner 36 | return client.post( 37 | `http://${HttpTestServer.domainName}:${this.envoyIngressPort}/inner`, 38 | request.body 39 | ); 40 | } 41 | 42 | async inner(request: Request): Promise { 43 | expect(request.body.message).toBe("ping"); 44 | const ctx = new EnvoyContext(request.headers as HttpHeader); 45 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 46 | expect(ctx.requestId).toBe(requestId); 47 | expect(ctx.traceId).toBe(traceId); 48 | expect(ctx.parentSpanId).toBe(innerParentId); 49 | return { message: "pong" }; 50 | } 51 | }(); 52 | 53 | await server.start(); 54 | 55 | try { 56 | const response = await simplePost( 57 | `http://${HttpTestServer.bindHost}:${server.envoyIngressPort}/wrapper`, 58 | { message: "ping" }, 59 | { "x-client-trace-id": CLIENT_TRACE_ID } 60 | ); 61 | expect(response.message).toBe("pong"); 62 | } finally { 63 | await server.stop(); 64 | } 65 | }); 66 | 67 | it("should handle timeout correctly", async () => { 68 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 69 | const WRAPPER_SLEEP_TIME = 200; 70 | let innerCalledCount = 0; 71 | 72 | const server = new class extends HttpTestServer { 73 | constructor() { 74 | super(11); 75 | } 76 | 77 | async wrapper(request: Request): Promise { 78 | const client = new EnvoyHttpClient(request.headers as HttpHeader); 79 | const ctx = client.envoyContext; 80 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 81 | const startTime = Date.now(); 82 | let http504Happened = false; 83 | // send request to inner 84 | try { 85 | const innerResponse = await client.post( 86 | `http://${HttpTestServer.domainName}:${this.envoyIngressPort}/inner`, 87 | request.body, 88 | { 89 | timeout: WRAPPER_SLEEP_TIME / 2 90 | } 91 | ); 92 | } catch (e) { 93 | expect(e.$statusCode).toBe(504); 94 | http504Happened = true; 95 | } 96 | expect(http504Happened).toBeTruthy(); 97 | const duration = Date.now() - startTime; 98 | expect(duration).toBeLessThan(WRAPPER_SLEEP_TIME); 99 | return { message: "pong" }; 100 | } 101 | 102 | async inner(request: Request): Promise { 103 | innerCalledCount++; 104 | if (innerCalledCount < 2) { 105 | await sleep(WRAPPER_SLEEP_TIME); 106 | } 107 | return { message: "pong" }; 108 | } 109 | }(); 110 | 111 | await server.start(); 112 | 113 | try { 114 | const response = await simplePost( 115 | `http://${HttpTestServer.bindHost}:${server.envoyIngressPort}/wrapper`, 116 | { message: "ping" }, 117 | { "x-client-trace-id": CLIENT_TRACE_ID } 118 | ); 119 | expect(innerCalledCount).toBe(1); 120 | expect(response.message).toBe("pong"); 121 | } finally { 122 | await server.stop(); 123 | } 124 | }); 125 | 126 | it("should handle retry correctly", async () => { 127 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 128 | let innerCalledCount = 0; 129 | 130 | const server = new class extends HttpTestServer { 131 | constructor() { 132 | super(12); 133 | } 134 | 135 | async wrapper(request: Request): Promise { 136 | const client = new EnvoyHttpClient(request.headers as HttpHeader); 137 | const ctx = client.envoyContext; 138 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 139 | // send request to inner 140 | const response = await client.post( 141 | `http://${HttpTestServer.domainName}:${this.envoyIngressPort}/inner`, 142 | request.body, 143 | { 144 | retryOn: [HttpRetryOn.RETRIABLE_4XX], 145 | maxRetries: 1 146 | } 147 | ); 148 | return response; 149 | } 150 | 151 | async inner(request: Request): Promise { 152 | innerCalledCount++; 153 | if (innerCalledCount < 2) { 154 | const err = new Error("HTTP 409"); 155 | Object.assign(err, { statusCode: 409 }); 156 | throw err; 157 | } 158 | return { message: "pong" }; 159 | } 160 | }(); 161 | 162 | await server.start(); 163 | 164 | try { 165 | const response = await simplePost( 166 | `http://${HttpTestServer.bindHost}:${server.envoyIngressPort}/wrapper`, 167 | { message: "ping" }, 168 | { "x-client-trace-id": CLIENT_TRACE_ID } 169 | ); 170 | expect(innerCalledCount).toBe(2); 171 | expect(response.message).toBe("pong"); 172 | } finally { 173 | await server.stop(); 174 | } 175 | }); 176 | 177 | it("should handle per retry timeout correctly", async () => { 178 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 179 | let innerCalledCount = 0; 180 | const WRAPPER_SLEEP_TIME = 100; 181 | 182 | const server = new class extends HttpTestServer { 183 | constructor() { 184 | super(13); 185 | } 186 | 187 | async wrapper(request: Request): Promise { 188 | const client = new EnvoyHttpClient(request.headers as HttpHeader); 189 | const ctx = client.envoyContext; 190 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 191 | 192 | let errorHappened = false; 193 | 194 | // send request to inner 195 | try { 196 | await client.post( 197 | `http://${HttpTestServer.domainName}:${this.envoyIngressPort}/inner`, 198 | request.body, 199 | { 200 | retryOn: [HttpRetryOn.RETRIABLE_4XX], 201 | maxRetries: 3, 202 | perTryTimeout: WRAPPER_SLEEP_TIME 203 | } 204 | ); 205 | } catch (e) { 206 | errorHappened = true; 207 | expect(e.$statusCode).toBe(504); 208 | expect(e.message).toBe("upstream request timeout"); 209 | } 210 | 211 | expect(errorHappened).toBeTruthy(); 212 | expect(innerCalledCount).toBe(2); 213 | 214 | return; 215 | } 216 | 217 | async inner(request: Request): Promise { 218 | innerCalledCount++; 219 | if (innerCalledCount === 2) { 220 | await sleep(WRAPPER_SLEEP_TIME * 1.2); 221 | } 222 | if (innerCalledCount < 3) { 223 | const err = new Error("HTTP 409"); 224 | Object.assign(err, { statusCode: 409 }); 225 | throw err; 226 | } 227 | return { message: "pong" }; 228 | } 229 | }(); 230 | 231 | await server.start(); 232 | 233 | try { 234 | await simplePost( 235 | `http://${HttpTestServer.bindHost}:${server.envoyIngressPort}/wrapper`, 236 | { message: "ping" }, 237 | { "x-client-trace-id": CLIENT_TRACE_ID } 238 | ); 239 | } finally { 240 | await server.stop(); 241 | } 242 | }); 243 | 244 | it("should propagate the tracing header directly in direct mode", async () => { 245 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 246 | let requestId: string | undefined; 247 | let traceId: string | undefined; 248 | let spanId: string | undefined; 249 | 250 | const server = new class extends HttpTestServer { 251 | constructor() { 252 | super(14); 253 | } 254 | 255 | async wrapper(request: Request): Promise { 256 | const init: EnvoyContextInit = { 257 | meta: request.headers as HttpHeader, 258 | directMode: true 259 | }; 260 | const ctx = new EnvoyContext(init); 261 | const client = new EnvoyHttpClient(ctx); 262 | // asserts 263 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 264 | requestId = ctx.requestId; 265 | traceId = ctx.traceId; 266 | spanId = ctx.spanId; 267 | // send request to inner 268 | return client.post( 269 | `http://${HttpTestServer.bindHost}:${this.envoyIngressPort}/inner`, 270 | request.body 271 | ); 272 | } 273 | 274 | async inner(request: Request): Promise { 275 | expect(request.body.message).toBe("ping"); 276 | const ctx = new EnvoyContext(request.headers as HttpHeader); 277 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 278 | expect(ctx.requestId).toBe(requestId); 279 | expect(ctx.traceId).toBe(traceId); 280 | expect(ctx.spanId).toBe(spanId); 281 | return { message: "pong" }; 282 | } 283 | }(); 284 | 285 | await server.start(); 286 | 287 | try { 288 | const response = await simplePost( 289 | `http://${HttpTestServer.bindHost}:${server.envoyIngressPort}/wrapper`, 290 | { message: "ping" }, 291 | { "x-client-trace-id": CLIENT_TRACE_ID } 292 | ); 293 | expect(response.message).toBe("pong"); 294 | } finally { 295 | await server.stop(); 296 | } 297 | }); 298 | 299 | it("should propagate the tracing header directly in direct mode", async () => { 300 | const CLIENT_TRACE_ID = `client-id-${Math.floor(Math.random() * 65536)}`; 301 | let requestId: string | undefined; 302 | let traceId: string | undefined; 303 | let spanId: string | undefined; 304 | 305 | const server = new class extends HttpTestServer { 306 | constructor() { 307 | super(15, true); 308 | } 309 | 310 | async wrapper(request: Request): Promise { 311 | const init: EnvoyContextInit = { 312 | meta: request.headers as HttpHeader 313 | }; 314 | const ctx = new EnvoyContext(init); 315 | const client = new EnvoyHttpClient(ctx); 316 | // asserts 317 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 318 | requestId = ctx.requestId; 319 | traceId = ctx.traceId; 320 | spanId = ctx.spanId; 321 | // send request to inner 322 | return client.post( 323 | `http://${HttpTestServer.bindHost}:${this.envoyIngressPort}/inner`, 324 | request.body 325 | ); 326 | } 327 | 328 | async inner(request: Request): Promise { 329 | expect(request.body.message).toBe("ping"); 330 | const ctx = new EnvoyContext(request.headers as HttpHeader); 331 | expect(ctx.clientTraceId).toBe(CLIENT_TRACE_ID); 332 | expect(ctx.requestId).toBe(requestId); 333 | expect(ctx.traceId).toBe(traceId); 334 | expect(ctx.spanId).toBe(spanId); 335 | return { message: "pong" }; 336 | } 337 | }(); 338 | 339 | await server.start(); 340 | 341 | try { 342 | const response = await simplePost( 343 | `http://${HttpTestServer.bindHost}:${server.envoyIngressPort}/wrapper`, 344 | { message: "ping" }, 345 | { "x-client-trace-id": CLIENT_TRACE_ID } 346 | ); 347 | expect(response.message).toBe("pong"); 348 | } finally { 349 | await server.stop(); 350 | } 351 | }); 352 | }); 353 | -------------------------------------------------------------------------------- /test/lib/common-test-server.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as util from "util"; 3 | import { spawn, ChildProcess } from "child_process"; 4 | import ZipkinMock from "./zipkin-mock"; 5 | import { sleep } from "./utils"; 6 | 7 | const readFile = util.promisify(fs.readFile); 8 | const writeFile = util.promisify(fs.writeFile); 9 | const unlink = util.promisify(fs.unlink); 10 | 11 | export const TEST_PORT_START = 10000; 12 | 13 | export default abstract class CommonTestServer { 14 | static bindHost = "127.0.0.1"; 15 | static domainName = "ping.pong.test"; 16 | 17 | envoy?: ChildProcess = undefined; 18 | readonly zipkin: ZipkinMock; 19 | readonly servicePort: number; 20 | readonly envoyIngressPort: number; 21 | readonly envoyEgressPort: number; 22 | readonly envoyAdminPort: number; 23 | readonly envoyConfigTemplate: string; 24 | readonly envoyConfigFileName: string; 25 | readonly useManagedHostHeader: boolean; 26 | 27 | envoyStdout = ""; 28 | envoyStderr = ""; 29 | 30 | constructor(envoyConfigTemplate: string, serverId: number, useManagedHostHeader: boolean) { 31 | let port = TEST_PORT_START + serverId * 10; 32 | this.servicePort = port++; 33 | this.envoyIngressPort = port++; 34 | this.envoyEgressPort = port++; 35 | this.envoyAdminPort = port++; 36 | const zipkinPort = port++; 37 | this.zipkin = new ZipkinMock(zipkinPort); 38 | this.envoyConfigTemplate = `${__dirname}/${envoyConfigTemplate}`; 39 | this.envoyConfigFileName = `/tmp/envoy-test-config-${this.servicePort}.yaml`; 40 | this.useManagedHostHeader = useManagedHostHeader; 41 | } 42 | 43 | async start() { 44 | let envoyConfig = (await readFile(this.envoyConfigTemplate)) 45 | .toString() 46 | .replace(/INGRESS_PORT/g, `${this.envoyIngressPort}`) 47 | .replace(/EGRESS_PORT/g, `${this.envoyEgressPort}`) 48 | .replace(/ADMIN_PORT/g, `${this.envoyAdminPort}`) 49 | .replace(/ZIPKIN_PORT/g, `${this.zipkin.port}`) 50 | .replace(/BIND_HOST/g, `${CommonTestServer.bindHost}`) 51 | .replace(/DOMAIN_NAME/g, `${CommonTestServer.domainName}`) 52 | .replace(/SERVICE_PORT/g, `${this.servicePort}`); 53 | if (this.useManagedHostHeader) { 54 | envoyConfig = envoyConfig.replace( 55 | /# MANAGED_HOST_REPLACEMENT/g, 56 | `- header: { "key": "x-tubi-envoy-managed-host", "value": "${CommonTestServer.domainName}:${this.envoyIngressPort}" }` 57 | ); 58 | } 59 | await writeFile(this.envoyConfigFileName, envoyConfig); 60 | this.envoy = spawn("envoy", [ 61 | "-c", 62 | this.envoyConfigFileName, 63 | "--service-cluster", 64 | "test-server", 65 | ]); 66 | this.zipkin.start(); 67 | this.envoy.stdout?.on("data", (data) => { 68 | this.envoyStdout += data; 69 | }); 70 | this.envoy.stderr?.on("data", (data) => { 71 | this.envoyStderr += data; 72 | }); 73 | this.envoy.once("exit", (code) => { 74 | if (code) { 75 | console.log(`Envoy exited abnormal: ${code}`); 76 | console.log("stdout", this.envoyStdout); 77 | console.log("stderr", this.envoyStderr); 78 | } 79 | }); 80 | // wait for envoy to be up 81 | await sleep(100); 82 | } 83 | 84 | async stop() { 85 | if (this.envoy) { 86 | this.envoy.kill(); 87 | } 88 | this.zipkin.stop(); 89 | await unlink(this.envoyConfigFileName); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /test/lib/envoy-grpc-config.yaml: -------------------------------------------------------------------------------- 1 | static_resources: 2 | listeners: 3 | - name: ingress_listener 4 | address: 5 | socket_address: 6 | address: BIND_HOST 7 | port_value: INGRESS_PORT 8 | filter_chains: 9 | - filters: 10 | - name: envoy.http_connection_manager 11 | typed_config: 12 | "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager 13 | codec_type: AUTO 14 | stat_prefix: ingress 15 | use_remote_address: false 16 | generate_request_id: true 17 | stat_prefix: grpc.test.ingress 18 | route_config: 19 | name: ingress_route_config 20 | virtual_hosts: 21 | - name: local_test_server 22 | domains: ["*"] 23 | routes: 24 | - match: 25 | prefix: / 26 | route: 27 | cluster: local_test_server 28 | request_headers_to_add: 29 | - header: 30 | key: x-tubi-envoy-egress-port 31 | value: "EGRESS_PORT" 32 | - header: 33 | key: x-tubi-envoy-egress-addr 34 | value: BIND_HOST 35 | # MANAGED_HOST_REPLACEMENT 36 | http_filters: 37 | - name: envoy.router 38 | typed_config: 39 | "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router 40 | dynamic_stats: true 41 | 42 | - name: egress_listener 43 | address: 44 | socket_address: 45 | address: BIND_HOST 46 | port_value: EGRESS_PORT 47 | filter_chains: 48 | - filters: 49 | - name: envoy.http_connection_manager 50 | typed_config: 51 | "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager 52 | codec_type: AUTO 53 | stat_prefix: egress 54 | use_remote_address: true 55 | stat_prefix: grpc.test.egress 56 | route_config: 57 | name: egress_route_config 58 | virtual_hosts: 59 | - name: remote_test_server 60 | domains: 61 | - DOMAIN_NAME:INGRESS_PORT 62 | routes: 63 | - match: 64 | prefix: / 65 | route: 66 | cluster: remote_test_server 67 | http_filters: 68 | - name: envoy.router 69 | typed_config: 70 | "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router 71 | dynamic_stats: true 72 | 73 | clusters: 74 | - name: local_test_server 75 | http2_protocol_options: {} 76 | type: STATIC 77 | connect_timeout: 0.25s 78 | lb_policy: ROUND_ROBIN 79 | load_assignment: 80 | cluster_name: local_test_server 81 | endpoints: 82 | - lb_endpoints: 83 | - endpoint: 84 | address: 85 | socket_address: 86 | address: BIND_HOST 87 | port_value: SERVICE_PORT 88 | 89 | - name: remote_test_server 90 | http2_protocol_options: {} 91 | type: STATIC 92 | connect_timeout: 0.25s 93 | lb_policy: ROUND_ROBIN 94 | load_assignment: 95 | cluster_name: remote_test_server 96 | endpoints: 97 | - lb_endpoints: 98 | - endpoint: 99 | address: 100 | socket_address: 101 | address: BIND_HOST 102 | port_value: INGRESS_PORT # route the traffic back 103 | 104 | # zipkin 105 | - name: zipkin 106 | type: STATIC 107 | connect_timeout: 0.25s 108 | lb_policy: ROUND_ROBIN 109 | load_assignment: 110 | cluster_name: zipkin 111 | endpoints: 112 | - lb_endpoints: 113 | - endpoint: 114 | address: 115 | socket_address: 116 | address: BIND_HOST 117 | port_value: ZIPKIN_PORT 118 | 119 | 120 | 121 | # Administration interface 122 | admin: 123 | access_log_path: /dev/null 124 | address: 125 | socket_address: 126 | address: 0.0.0.0 127 | port_value: ADMIN_PORT 128 | 129 | tracing: 130 | http: 131 | name: envoy.zipkin 132 | typed_config: 133 | "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig 134 | collector_cluster: zipkin 135 | collector_endpoint: /api/v1/spans 136 | collector_endpoint_version: HTTP_JSON 137 | 138 | -------------------------------------------------------------------------------- /test/lib/envoy-http-config.yaml: -------------------------------------------------------------------------------- 1 | static_resources: 2 | listeners: 3 | - name: ingress_listener 4 | address: 5 | socket_address: 6 | address: BIND_HOST 7 | port_value: INGRESS_PORT 8 | filter_chains: 9 | - filters: 10 | - name: envoy.http_connection_manager 11 | typed_config: 12 | "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager 13 | codec_type: HTTP1 14 | stat_prefix: ingress 15 | use_remote_address: false 16 | generate_request_id: true 17 | stat_prefix: http.test.ingress 18 | route_config: 19 | name: ingress_route_config 20 | virtual_hosts: 21 | - name: local_test_server 22 | domains: ["*"] 23 | routes: 24 | - match: 25 | prefix: / 26 | route: 27 | cluster: local_test_server 28 | request_headers_to_add: 29 | - header: 30 | key: x-tubi-envoy-egress-port 31 | value: "EGRESS_PORT" 32 | - header: 33 | key: x-tubi-envoy-egress-addr 34 | value: BIND_HOST 35 | # MANAGED_HOST_REPLACEMENT 36 | http_filters: 37 | - name: envoy.router 38 | typed_config: 39 | "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router 40 | dynamic_stats: true 41 | 42 | - name: egress_listener 43 | address: 44 | socket_address: 45 | address: BIND_HOST 46 | port_value: EGRESS_PORT 47 | filter_chains: 48 | - filters: 49 | - name: envoy.http_connection_manager 50 | typed_config: 51 | "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager 52 | codec_type: HTTP1 53 | stat_prefix: egress 54 | use_remote_address: true 55 | stat_prefix: http.test.egress 56 | route_config: 57 | name: egress_route_config 58 | virtual_hosts: 59 | - name: remote_test_server 60 | domains: 61 | - DOMAIN_NAME:INGRESS_PORT 62 | routes: 63 | - match: 64 | prefix: / 65 | route: 66 | cluster: remote_test_server 67 | http_filters: 68 | - name: envoy.router 69 | typed_config: 70 | "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router 71 | dynamic_stats: true 72 | 73 | clusters: 74 | - name: local_test_server 75 | type: STATIC 76 | connect_timeout: 0.25s 77 | lb_policy: ROUND_ROBIN 78 | load_assignment: 79 | cluster_name: local_test_server 80 | endpoints: 81 | - lb_endpoints: 82 | - endpoint: 83 | address: 84 | socket_address: 85 | address: BIND_HOST 86 | port_value: SERVICE_PORT 87 | 88 | - name: remote_test_server 89 | type: STATIC 90 | connect_timeout: 0.25s 91 | lb_policy: ROUND_ROBIN 92 | load_assignment: 93 | cluster_name: remote_test_server 94 | endpoints: 95 | - lb_endpoints: 96 | - endpoint: 97 | address: 98 | socket_address: 99 | address: BIND_HOST 100 | port_value: INGRESS_PORT # route the traffic back 101 | 102 | # zipkin 103 | - name: zipkin 104 | type: STATIC 105 | connect_timeout: 0.25s 106 | lb_policy: ROUND_ROBIN 107 | load_assignment: 108 | cluster_name: zipkin 109 | endpoints: 110 | - lb_endpoints: 111 | - endpoint: 112 | address: 113 | socket_address: 114 | address: BIND_HOST 115 | port_value: ZIPKIN_PORT 116 | 117 | 118 | # Administration interface 119 | admin: 120 | access_log_path: /dev/null 121 | address: 122 | socket_address: 123 | address: 0.0.0.0 124 | port_value: ADMIN_PORT 125 | 126 | tracing: 127 | http: 128 | name: envoy.zipkin 129 | typed_config: 130 | "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig 131 | collector_cluster: zipkin 132 | collector_endpoint: /api/v1/spans 133 | collector_endpoint_version: HTTP_JSON 134 | 135 | -------------------------------------------------------------------------------- /test/lib/grpc-test-server.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from "@grpc/grpc-js"; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { 4 | ServerUnaryCall, 5 | sendUnaryData, 6 | ServiceError, 7 | ServerReadableStream, 8 | ServerWritableStream, 9 | ServerDuplexStream, 10 | } from "@grpc/grpc-js"; 11 | import * as protoLoader from "@grpc/proto-loader"; 12 | import CommonTestServer from "./common-test-server"; 13 | import envoyProtoDecorator from "../../src/envoy-proto-decorator"; 14 | import { 15 | EnvoyClientConstructor, 16 | RequestFunc, 17 | EnvoyClient, 18 | ClientStreamFunc, 19 | ServerStreamFunc, 20 | BidiStreamFunc, 21 | } from "../../src/types"; 22 | 23 | const PROTO_PATH = __dirname + "/ping.proto"; 24 | const testProto: any = grpc.loadPackageDefinition(protoLoader.loadSync(PROTO_PATH)).test; 25 | 26 | export interface PingEnvoyClient extends EnvoyClient { 27 | inner: RequestFunc; 28 | wrapper: RequestFunc; 29 | clientStream: ClientStreamFunc; 30 | serverStream: ServerStreamFunc; 31 | bidiStream: BidiStreamFunc; 32 | } 33 | 34 | export const { Ping } = testProto; 35 | // tslint:disable-next-line:variable-name 36 | export const PingEnvoyClient = envoyProtoDecorator(Ping); 37 | 38 | function wrapImpl(func: (call: ServerUnaryCall) => Promise) { 39 | return (call: ServerUnaryCall, callback: sendUnaryData) => { 40 | func(call) 41 | .then((result) => { 42 | // tslint:disable-next-line:no-null-keyword 43 | callback(null, result); 44 | }) 45 | .catch((reason) => { 46 | callback(reason, undefined); 47 | }); 48 | }; 49 | } 50 | 51 | export default abstract class GrpcTestServer extends CommonTestServer { 52 | readonly server: grpc.Server; 53 | 54 | constructor (serverId: number, useManagedHostHeader = false) { 55 | super("./envoy-grpc-config.yaml", serverId, useManagedHostHeader); 56 | this.server = new grpc.Server(); 57 | this.server.addService(Ping.service, { 58 | wrapper: wrapImpl(this.wrapper.bind(this)), 59 | inner: wrapImpl(this.inner.bind(this)), 60 | clientStream: this.clientStream.bind(this), 61 | serverStream: this.serverStream.bind(this), 62 | bidiStream: this.bidiStream.bind(this), 63 | }); 64 | } 65 | 66 | async wrapper(call: ServerUnaryCall): Promise { 67 | console.log("client requested:", call.request); 68 | return { message: "pong" }; 69 | } 70 | 71 | async inner(call: ServerUnaryCall): Promise { 72 | console.log("client requested:", call.request); 73 | return { message: "pong" }; 74 | } 75 | 76 | clientStream(call: ServerReadableStream, callback: sendUnaryData): void { 77 | call.on("data", (data) => { 78 | console.log("got data from client:", data); 79 | }); 80 | call.on("error", (err) => { 81 | callback(err, undefined); 82 | }); 83 | call.on("end", () => { 84 | // tslint:disable-next-line:no-null-keyword 85 | callback(null, { message: "default client stream implementation." }); 86 | }); 87 | } 88 | 89 | serverStream(call: ServerWritableStream): void { 90 | console.log("client requested:", call.request); 91 | call.write({ message: "server send a message" }); 92 | call.on("end", () => { 93 | call.end(); 94 | }); 95 | } 96 | 97 | bidiStream(call: ServerDuplexStream): void { 98 | console.log("should have metadata?", call); 99 | call.write({ message: "server send a message" }); 100 | call.on("data", (data) => { 101 | // 102 | }); 103 | call.on("end", () => { 104 | call.end(); 105 | }); 106 | } 107 | 108 | async bind(callback: () => void) { 109 | this.server.bindAsync( 110 | `${GrpcTestServer.bindHost}:${this.servicePort}`, 111 | grpc.ServerCredentials.createInsecure(), 112 | async (error, port) => { 113 | if (error) { 114 | return console.log(error); 115 | } 116 | callback(); 117 | } 118 | ); 119 | } 120 | 121 | async start() { 122 | this.server.start(); 123 | // start server 124 | await super.start(); 125 | } 126 | 127 | async stop() { 128 | await super.stop(); 129 | this.server.forceShutdown(); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/lib/http-test-server.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { Server, IncomingMessage, ServerResponse } from "http"; 4 | import { APPLICATION_JSON } from "../../src/envoy-http-client"; 5 | import CommonTestServer from "./common-test-server"; 6 | 7 | export interface HttpError extends Error { 8 | statusCode?: number; 9 | } 10 | 11 | export interface Request extends IncomingMessage { 12 | body?: any; 13 | } 14 | 15 | function stringifyError(err: Error) { 16 | return JSON.stringify(err, Object.getOwnPropertyNames(err)); 17 | } 18 | 19 | export default abstract class HttpTestServer extends CommonTestServer { 20 | readonly server: Server; 21 | 22 | constructor(serverId: number, useManagedHostHeader = false) { 23 | super("./envoy-http-config.yaml", serverId, useManagedHostHeader); 24 | this.server = http.createServer(this.processRequest); 25 | } 26 | 27 | abstract async wrapper(request: Request): Promise; 28 | abstract async inner(request: Request): Promise; 29 | 30 | private callAsync( 31 | request: Request, 32 | asyncFunc: (request: any) => Promise, 33 | res: ServerResponse 34 | ): void { 35 | asyncFunc 36 | .call(this, request) 37 | .then((response: any) => { 38 | res.statusCode = 200; 39 | res.write(JSON.stringify(response)); 40 | res.end(); 41 | }) 42 | .catch((err: HttpError) => { 43 | res.statusCode = err.statusCode || 500; 44 | res.write(stringifyError(err)); 45 | res.end(); 46 | }); 47 | } 48 | 49 | private processRequest = (req: IncomingMessage, res: ServerResponse) => { 50 | let body = ""; 51 | req.on("data", (chunk) => (body += chunk)); 52 | req.on("end", () => { 53 | res.setHeader("content-type", APPLICATION_JSON); 54 | if (req.method === "POST") { 55 | const request = req as Request; 56 | request.body = JSON.parse(body); 57 | if (req.url === "/wrapper") { 58 | this.callAsync(request, this.wrapper, res); 59 | return; 60 | } else if (req.url === "/inner") { 61 | this.callAsync(request, this.inner, res); 62 | return; 63 | } 64 | } 65 | res.statusCode = 404; 66 | res.write(stringifyError(new Error("HTTP 404 NOT FOUND"))); 67 | res.end(); 68 | }); 69 | }; 70 | 71 | async start() { 72 | this.server.listen(this.servicePort); 73 | // start server 74 | await super.start(); 75 | } 76 | 77 | async stop() { 78 | await super.stop(); 79 | this.server.close(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /test/lib/ping.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_multiple_files = true; 4 | option java_package = "io.tubi.envoy-test"; 5 | option java_outer_classname = "TestServerProto"; 6 | option objc_class_prefix = "TS"; 7 | 8 | package test; 9 | 10 | service Ping { 11 | rpc Wrapper (Request) returns (Response); 12 | rpc Inner (Request) returns (Response); 13 | rpc ClientStream(stream Request) returns (Response); 14 | rpc ServerStream(Request) returns (stream Response); 15 | rpc BidiStream(stream Request) returns (stream Response); 16 | } 17 | 18 | message Request { 19 | string message = 1; 20 | } 21 | 22 | message Response { 23 | string message = 1; 24 | } 25 | -------------------------------------------------------------------------------- /test/lib/simple-post.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { APPLICATION_JSON } from "../../src/envoy-http-client"; 3 | import { HttpHeader } from "../../src/types"; 4 | 5 | export default async function simplePost( 6 | url: string, 7 | body: any, 8 | header?: HttpHeader 9 | ): Promise { 10 | const response = await fetch(url, { 11 | headers: { 12 | "content-type": APPLICATION_JSON, 13 | // tslint:disable-next-line:object-literal-key-quotes 14 | accept: APPLICATION_JSON, 15 | ...header 16 | }, 17 | method: "POST", 18 | body: JSON.stringify(body) 19 | }); 20 | 21 | const statusCode = response.status; 22 | const json = await response.json(); 23 | if (statusCode !== 200) { 24 | json.$statusCode = statusCode; 25 | throw json; 26 | } 27 | return json; 28 | } 29 | -------------------------------------------------------------------------------- /test/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(milliseconds: number) { 2 | return new Promise(resolve => { 3 | setTimeout(() => resolve(), milliseconds); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /test/lib/zipkin-mock.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { Server, IncomingMessage, ServerResponse } from "http"; 4 | 5 | export default class ZipkinMock { 6 | readonly server: Server; 7 | readonly port: number; 8 | 9 | constructor(port: number) { 10 | this.server = http.createServer(this.process_request); 11 | this.port = port; 12 | } 13 | 14 | private process_request(req: IncomingMessage, res: ServerResponse) { 15 | let body = ""; 16 | req.on("data", (chunk) => (body += chunk)); 17 | req.on("close", () => { 18 | const json = JSON.parse(body); 19 | // tracing data is comming too late so omit the validation for now 20 | res.statusCode = 204; 21 | res.end(); 22 | }); 23 | } 24 | 25 | start() { 26 | this.server.listen(this.port); 27 | } 28 | 29 | stop() { 30 | this.server.close(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tools/gh-pages-publish.ts: -------------------------------------------------------------------------------- 1 | const { cd, exec, echo, touch } = require("shelljs"); 2 | const { readFileSync } = require("fs"); 3 | const url = require("url"); 4 | 5 | let repoUrl; 6 | let pkg = JSON.parse(readFileSync("package.json") as any); 7 | if (typeof pkg.repository === "object") { 8 | if (!pkg.repository.hasOwnProperty("url")) { 9 | throw new Error("URL does not exist in repository section"); 10 | } 11 | repoUrl = pkg.repository.url; 12 | } else { 13 | repoUrl = pkg.repository; 14 | } 15 | 16 | let parsedUrl = url.parse(repoUrl); 17 | let repository = (parsedUrl.host || "") + (parsedUrl.path || ""); 18 | let ghToken = process.env.GH_TOKEN; 19 | 20 | echo("Deploying docs!!!"); 21 | cd("dist/docs"); 22 | touch(".nojekyll"); 23 | exec("git init"); 24 | exec("git add ."); 25 | exec('git config user.name "Yingyu Cheng"'); 26 | exec('git config user.email "github@winguse.com"'); 27 | exec('git commit -m "docs(docs): update gh-pages"'); 28 | exec( 29 | `git push --force --quiet "https://${ghToken}@${repository}" master:gh-pages` 30 | ); 31 | echo("Docs deployed!!"); 32 | -------------------------------------------------------------------------------- /tools/semantic-release-prepare.ts: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { fork } = require("child_process"); 3 | const colors = require("colors"); 4 | 5 | const { readFileSync, writeFileSync } = require("fs"); 6 | const pkg = JSON.parse( 7 | readFileSync(path.resolve(__dirname, "..", "package.json")) 8 | ); 9 | 10 | pkg.scripts.prepush = "npm run test:prod && npm run build"; 11 | pkg.scripts.commitmsg = "validate-commit-msg"; 12 | 13 | writeFileSync( 14 | path.resolve(__dirname, "..", "package.json"), 15 | JSON.stringify(pkg, undefined, 2) 16 | ); 17 | 18 | // Call husky to set up the hooks 19 | fork(path.resolve(__dirname, "..", "node_modules", "husky", "bin", "install")) 20 | 21 | console.log(); 22 | console.log(colors.green("Done!!")) 23 | console.log(); 24 | 25 | if (pkg.repository.url.trim()) { 26 | console.log(colors.cyan("Now run:")); 27 | console.log(colors.cyan(" npm install -g semantic-release-cli")); 28 | console.log(colors.cyan(" semantic-release setup")); 29 | console.log(); 30 | console.log( 31 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 32 | ); 33 | console.log(); 34 | console.log( 35 | colors.gray( 36 | 'Note: Make sure "repository.url" in your package.json is correct before' 37 | ) 38 | ); 39 | } else { 40 | console.log( 41 | colors.red( 42 | 'First you need to set the "repository.url" property in package.json' 43 | ) 44 | ); 45 | console.log(colors.cyan("Then run:")); 46 | console.log(colors.cyan(" npm install -g semantic-release-cli")); 47 | console.log(colors.cyan(" semantic-release setup")); 48 | console.log(); 49 | console.log( 50 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 51 | ); 52 | } 53 | 54 | console.log(); 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es6", 5 | "module":"es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | // "inlineSourceMap": true, 10 | "declaration": true, 11 | "allowSyntheticDefaultImports": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "declarationDir": "dist/types", 15 | "outDir": "dist/es", 16 | "typeRoots": [ 17 | "node_modules/@types" 18 | ] 19 | }, 20 | "include": [ 21 | "src" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:latest", 3 | "autoFixOnSave": true, 4 | "rules": { 5 | "array-type": [true, "array"], 6 | "ban-types": { 7 | "options": [ 8 | ["Object", "Avoid using the `Object` type. Did you mean `object`?"], 9 | ["Function", "Avoid using the `Function` type. Prefer a specific function type, like `() => void`, or use `ts.AnyFunction`."], 10 | ["Boolean", "Avoid using the `Boolean` type. Did you mean `boolean`?"], 11 | ["Number", "Avoid using the `Number` type. Did you mean `number`?"], 12 | ["String", "Avoid using the `String` type. Did you mean `string`?"] 13 | ] 14 | }, 15 | "class-name": true, 16 | "comment-format": [true, 17 | "check-space" 18 | ], 19 | "curly":[true, "ignore-same-line"], 20 | "indent": [true, 21 | "spaces" 22 | ], 23 | "interface-name": [true, "never-prefix"], 24 | "interface-over-type-literal": true, 25 | "jsdoc-format": true, 26 | "linebreak-style": [true, "LF"], 27 | "no-inferrable-types": true, 28 | "no-internal-module": true, 29 | "no-null-keyword": true, 30 | "no-switch-case-fall-through": true, 31 | "no-trailing-whitespace": [true, "ignore-template-strings"], 32 | "no-var-keyword": true, 33 | "object-literal-shorthand": true, 34 | "one-line": [true, 35 | "check-open-brace", 36 | "check-whitespace" 37 | ], 38 | "prefer-const": true, 39 | "quotemark": [true, 40 | "double", 41 | "avoid-escape" 42 | ], 43 | "semicolon": [true, "always", "ignore-bound-class-methods"], 44 | "space-within-parens": true, 45 | "triple-equals": true, 46 | "typedef-whitespace": [ 47 | true, 48 | { 49 | "call-signature": "nospace", 50 | "index-signature": "nospace", 51 | "parameter": "nospace", 52 | "property-declaration": "nospace", 53 | "variable-declaration": "nospace" 54 | }, 55 | { 56 | "call-signature": "onespace", 57 | "index-signature": "onespace", 58 | "parameter": "onespace", 59 | "property-declaration": "onespace", 60 | "variable-declaration": "onespace" 61 | } 62 | ], 63 | "whitespace": [true, 64 | "check-branch", 65 | "check-decl", 66 | "check-operator", 67 | "check-module", 68 | "check-separator", 69 | "check-type" 70 | ], 71 | 72 | // Config different from tslint:latest 73 | "no-implicit-dependencies": [true, "dev"], 74 | "object-literal-key-quotes": [true, "consistent-as-needed"], 75 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], 76 | 77 | // TODO 78 | "arrow-parens": false, // [true, "ban-single-arg-parens"] 79 | "arrow-return-shorthand": false, 80 | "forin": false, 81 | "member-access": false, // [true, "no-public"] 82 | "no-conditional-assignment": false, 83 | "no-console": false, 84 | "no-debugger": false, 85 | "no-empty-interface": false, 86 | "no-eval": false, 87 | "no-object-literal-type-assertion": false, 88 | "no-shadowed-variable": false, 89 | "no-submodule-imports": false, 90 | "no-unnecessary-initializer": false, 91 | "no-var-requires": false, 92 | "ordered-imports": false, 93 | "prefer-conditional-expression": false, 94 | "radix": false, 95 | "trailing-comma": false, 96 | 97 | // These should be done automatically by a formatter. https://github.com/Microsoft/TypeScript/issues/18340 98 | "align": false, 99 | "eofline": false, 100 | "max-line-length": false, 101 | "no-consecutive-blank-lines": false, 102 | "space-before-function-paren": false, 103 | 104 | // Not doing 105 | "ban-comma-operator": false, 106 | "max-classes-per-file": false, 107 | "member-ordering": false, 108 | "no-angle-bracket-type-assertion": false, 109 | "no-bitwise": false, 110 | "no-namespace": false, 111 | "no-reference": false, 112 | "object-literal-sort-keys": false, 113 | "one-variable-per-declaration": false 114 | } 115 | } 116 | --------------------------------------------------------------------------------