├── .github ├── FUNDING.yml └── workflows │ └── CI.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── lib ├── compression-filter.js ├── handler.js ├── index.d.ts ├── index.js ├── logging.js ├── metadata.js ├── options.js ├── server-call.js ├── server-credentials.js ├── server-resolver.js ├── server-session.js ├── server.js ├── status.js ├── stream-decoder.js └── utils.js ├── package.json └── test ├── common.js ├── compression.js ├── deadline.js ├── errors.js ├── fixtures ├── README ├── ca.pem ├── server1.key └── server1.pem ├── logging.js ├── metadata.js ├── options.js ├── proto ├── echo_service.proto ├── math.proto ├── test_messages.proto └── test_service.proto ├── server-credentials.js ├── server-resolver.js ├── server.js ├── stream-decoder.js └── utils.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [cjihrig] 4 | patreon: cjihrig 5 | custom: https://www.paypal.me/cjihrig/5 6 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: grpc-server-js CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | matrix: 16 | node-version: [10.x, 12.x, 13.x, 14.x, 15.x] 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | - run: npm test 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | .vscode 61 | 62 | dev-client.js 63 | dev-server.js 64 | dev-service.proto 65 | 66 | .node_bash_completion 67 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Colin J. Ihrig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grpc-server-js 2 | 3 | [![Current Version](https://img.shields.io/npm/v/grpc-server-js.svg)](https://www.npmjs.org/package/grpc-server-js) 4 | ![grpc-server-js CI](https://github.com/cjihrig/grpc-server-js/workflows/grpc-server-js%20CI/badge.svg) 5 | ![Dependencies](http://img.shields.io/david/cjihrig/grpc-server-js.svg) 6 | [![belly-button-style](https://img.shields.io/badge/eslint-bellybutton-4B32C3.svg)](https://github.com/cjihrig/belly-button) 7 | 8 | Pure JavaScript gRPC Server 9 | 10 | ## Documentation 11 | 12 | The goal is to be largely compatible with the existing [`Server`](https://grpc.io/grpc/node/grpc.Server.html) implementation. 13 | 14 | ## Features 15 | 16 | - [Unary calls](https://grpc.github.io/grpc/node/grpc-ServerUnaryCall.html). 17 | - [Streaming client request calls](https://grpc.github.io/grpc/node/grpc-ServerReadableStream.html). 18 | - [Streaming server response calls](https://grpc.github.io/grpc/node/grpc-ServerWritableStream.html). 19 | - [Bidirectional streaming calls](https://grpc.github.io/grpc/node/grpc-ServerDuplexStream.html). 20 | - Deadline and cancellation support. 21 | - Support for gzip and deflate compression, as well as uncompressed messages. 22 | - [Server credentials](https://grpc.github.io/grpc/node/grpc.ServerCredentials.html) for handling both secure and insecure calls. 23 | - [gRPC Metadata](https://grpc.github.io/grpc/node/grpc.Metadata.html). 24 | - gRPC logging. 25 | - No production dependencies. 26 | - No C++ dependencies. This implementation relies on Node's [`http2`](https://nodejs.org/api/http2.html) module. 27 | - Supports the following gRPC server options: 28 | - `grpc.http2.max_frame_size` 29 | - `grpc.keepalive_time_ms` 30 | - `grpc.keepalive_timeout_ms` 31 | - `grpc.max_concurrent_streams` 32 | - `grpc.max_receive_message_length` 33 | - `grpc.max_send_message_length` 34 | - All possible options and their descriptions are available [here](https://github.com/grpc/grpc/blob/master/include/grpc/impl/codegen/grpc_types.h). 35 | - Supports the following gRPC environment variables: 36 | - `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` 37 | - `GRPC_SSL_CIPHER_SUITES` 38 | - `GRPC_VERBOSITY` 39 | - All possible environment variables and their descriptions are available [here](https://github.com/grpc/grpc/blob/master/doc/environment_variables.md). 40 | 41 | ## Public API Deviations from the Existing `grpc.Server` 42 | 43 | - `Server.prototype.bind()` is an `async` function. 44 | - The deprecated `Server.prototype.addProtoService()` is not implemented. 45 | - `Server.prototype.addHttp2Port()` is not implemented. 46 | 47 | ## Useful References 48 | 49 | - [What is gRPC?](https://grpc.io/docs/guides/index.html) 50 | - [gRPC over HTTP2](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md) 51 | - [gRPC Compression](https://github.com/grpc/grpc/blob/master/doc/compression.md) 52 | - [gRPC Environment Variables](https://github.com/grpc/grpc/blob/master/doc/environment_variables.md) 53 | - [gRPC Keepalive](https://github.com/grpc/grpc/blob/master/doc/keepalive.md) 54 | - [gRPC Name Resolution](https://github.com/grpc/grpc/blob/master/doc/naming.md) 55 | - [gRPC Status Codes](https://github.com/grpc/grpc/blob/master/doc/statuscodes.md) 56 | 57 | ## Acknowledgement 58 | 59 | This module is heavily inspired by the [`grpc`](https://www.npmjs.com/package/grpc) native module. Some of the source code is adapted from the [`@grpc/grpc-js`](https://www.npmjs.com/package/@grpc/grpc-js) module. 60 | -------------------------------------------------------------------------------- /lib/compression-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Zlib = require('zlib'); 3 | const kGrpcEncodingHeader = 'grpc-encoding'; 4 | const kGrpcAcceptEncodingHeader = 'grpc-accept-encoding'; 5 | 6 | 7 | class CompressionHandler { 8 | async writeMessage (message, compress) { 9 | if (compress) { 10 | message = await this.compressMessage(message); 11 | } 12 | 13 | const output = Buffer.allocUnsafe(message.byteLength + 5); 14 | 15 | output.writeUInt8(compress ? 1 : 0, 0); 16 | output.writeUInt32BE(message.byteLength, 1); 17 | message.copy(output, 5); 18 | 19 | return output; 20 | } 21 | 22 | async readMessage (data) { 23 | const compressed = data.readUInt8(0) === 1; 24 | let message = data.slice(5); 25 | 26 | if (compressed) { 27 | message = await this.decompressMessage(message); 28 | } 29 | 30 | return message; 31 | } 32 | } 33 | 34 | 35 | class IdentityHandler extends CompressionHandler { 36 | constructor () { 37 | super(); 38 | this.name = 'identity'; 39 | } 40 | 41 | compressMessage (message) { // eslint-disable-line class-methods-use-this 42 | throw new Error('Identity encoding does not support compression'); 43 | } 44 | 45 | decompressMessage (message) { // eslint-disable-line class-methods-use-this 46 | throw new Error('Identity encoding does not support compression'); 47 | } 48 | 49 | // eslint-disable-next-line class-methods-use-this 50 | writeMessage (message, compress) { 51 | const output = Buffer.allocUnsafe(message.byteLength + 5); 52 | 53 | // Identity compression messages should be marked as uncompressed. 54 | output.writeUInt8(0, 0); 55 | output.writeUInt32BE(message.length, 1); 56 | message.copy(output, 5); 57 | 58 | return output; 59 | } 60 | } 61 | 62 | 63 | class GzipHandler extends CompressionHandler { 64 | constructor () { 65 | super(); 66 | this.name = 'gzip'; 67 | } 68 | 69 | compressMessage (message) { // eslint-disable-line class-methods-use-this 70 | return new Promise((resolve, reject) => { 71 | Zlib.gzip(message, (err, output) => { 72 | if (err) { 73 | reject(err); 74 | } else { 75 | resolve(output); 76 | } 77 | }); 78 | }); 79 | } 80 | 81 | decompressMessage (message) { // eslint-disable-line class-methods-use-this 82 | return new Promise((resolve, reject) => { 83 | Zlib.unzip(message, (err, output) => { 84 | if (err) { 85 | reject(err); 86 | } else { 87 | resolve(output); 88 | } 89 | }); 90 | }); 91 | } 92 | } 93 | 94 | 95 | class DeflateHandler extends CompressionHandler { 96 | constructor () { 97 | super(); 98 | this.name = 'deflate'; 99 | } 100 | 101 | compressMessage (message) { // eslint-disable-line class-methods-use-this 102 | return new Promise((resolve, reject) => { 103 | Zlib.deflate(message, (err, output) => { 104 | if (err) { 105 | reject(err); 106 | } else { 107 | resolve(output); 108 | } 109 | }); 110 | }); 111 | } 112 | 113 | decompressMessage (message) { // eslint-disable-line class-methods-use-this 114 | return new Promise((resolve, reject) => { 115 | Zlib.inflate(message, (err, output) => { 116 | if (err) { 117 | reject(err); 118 | } else { 119 | resolve(output); 120 | } 121 | }); 122 | }); 123 | } 124 | } 125 | 126 | 127 | // This class tracks all compression methods supported by a server. 128 | // TODO: Export this class and make it configurable by the Server class. 129 | class CompressionMethodMap { 130 | constructor () { 131 | this.default = null; 132 | this.accepts = null; 133 | this.map = new Map(); 134 | this.register('identity', IdentityHandler); 135 | this.register('deflate', DeflateHandler); 136 | this.register('gzip', GzipHandler); 137 | this.setDefault('identity'); 138 | } 139 | 140 | register (compressionName, compressionMethodConstructor) { 141 | if (typeof compressionName !== 'string') { 142 | throw new TypeError('Compression method must be a string'); 143 | } 144 | 145 | if (typeof compressionMethodConstructor !== 'function') { 146 | throw new TypeError('Compression method constructor must be a function'); 147 | } 148 | 149 | this.map.set(compressionName, compressionMethodConstructor); 150 | this.accepts = Array.from(this.map.keys()); 151 | } 152 | 153 | setDefault (compressionName) { 154 | if (typeof compressionName !== 'string') { 155 | throw new TypeError('Compression method must be a string'); 156 | } 157 | 158 | if (!this.map.has(compressionName)) { 159 | // TODO: This error code must be UNIMPLEMENTED. 160 | throw new Error(`Compression method not supported: ${compressionName}`); 161 | } 162 | 163 | this.default = compressionName; 164 | } 165 | 166 | getDefaultInstance () { 167 | return this.getInstance(this.default); 168 | } 169 | 170 | getInstance (compressionName) { 171 | if (typeof compressionName !== 'string') { 172 | throw new TypeError('Compression method must be a string'); 173 | } 174 | 175 | const Ctor = this.map.get(compressionName); 176 | 177 | if (Ctor === undefined) { 178 | // TODO: This error code must be UNIMPLEMENTED. 179 | throw new Error(`Compression method not supported: ${compressionName}`); 180 | } 181 | 182 | return new Ctor(); 183 | } 184 | } 185 | 186 | 187 | const compressionMethods = new CompressionMethodMap(); 188 | const defaultCompression = compressionMethods.getDefaultInstance(); 189 | const defaultAcceptedEncoding = compressionMethods.accepts; 190 | 191 | 192 | class CompressionFilter { 193 | constructor () { 194 | this.supportedMethods = compressionMethods; 195 | this.send = defaultCompression; 196 | this.receive = defaultCompression; 197 | this.accepts = defaultAcceptedEncoding; 198 | } 199 | 200 | receiveMetadata (metadata) { 201 | const receiveEncoding = metadata.get(kGrpcEncodingHeader); 202 | 203 | if (receiveEncoding.length > 0) { 204 | const encoding = receiveEncoding[0]; 205 | 206 | if (encoding !== this.receive.name) { 207 | this.receive = this.supportedMethods.getInstance(encoding); 208 | } 209 | } 210 | 211 | const acceptedEncoding = metadata.get(kGrpcAcceptEncodingHeader); 212 | 213 | if (acceptedEncoding.length > 0) { 214 | this.accepts = acceptedEncoding; 215 | } 216 | 217 | // Check that the client supports the incoming compression type. 218 | if (this.accepts.includes(this.receive.name)) { 219 | if (this.send.name !== this.receive.name) { 220 | this.send = this.supportedMethods.getInstance(this.receive.name); 221 | } 222 | } else { 223 | // The client does not support this compression type, so send 224 | // back uncompressed data. 225 | if (this.send.name !== 'identity') { 226 | this.send = this.supportedMethods.getInstance(this.receive.name); 227 | } 228 | } 229 | 230 | metadata.remove(kGrpcEncodingHeader); 231 | metadata.remove(kGrpcAcceptEncodingHeader); 232 | 233 | return metadata; 234 | } 235 | 236 | serializeMessage (message) { 237 | // TODO: Add support for flags (compression) later. 238 | return this.send.writeMessage(message, false); 239 | } 240 | 241 | deserializeMessage (message) { 242 | return this.receive.readMessage(message); 243 | } 244 | } 245 | 246 | module.exports = { 247 | CompressionFilter, 248 | CompressionMethodMap, 249 | DeflateHandler, 250 | GzipHandler, 251 | IdentityHandler 252 | }; 253 | -------------------------------------------------------------------------------- /lib/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const EventEmitter = require('events'); 3 | const { Duplex, Readable, Writable } = require('stream'); 4 | const Status = require('./status'); 5 | const { StreamDecoder } = require('./stream-decoder'); 6 | const { hasGrpcStatusCode } = require('./utils'); 7 | const kCall = Symbol('call'); 8 | const kReadableState = Symbol('readableState'); 9 | const kReadablePushOrBufferMessage = Symbol('readablePushOrBufferMessage'); 10 | const kReadablePushMessage = Symbol('readablePushMessage'); 11 | 12 | 13 | class ServerUnaryCall extends EventEmitter { 14 | constructor (call, metadata) { 15 | super(); 16 | setUpHandler(this, call, metadata); 17 | this.request = undefined; 18 | } 19 | } 20 | 21 | ServerUnaryCall.prototype.sendMetadata = sendMetadata; 22 | ServerUnaryCall.prototype.getPeer = getPeer; 23 | ServerUnaryCall.prototype.getDeadline = getDeadline; 24 | 25 | 26 | class ServerReadableStream extends Readable { 27 | constructor (call, metadata) { 28 | super({ objectMode: true }); 29 | setUpHandler(this, call, metadata); 30 | setUpReadable(this); 31 | } 32 | 33 | _read (size) { 34 | this[kReadableState].canPush = true; 35 | const { messagesToPush } = this[kReadableState]; 36 | 37 | while (messagesToPush.length > 0) { 38 | const nextMessage = messagesToPush.shift(); 39 | const canPush = this.push(nextMessage); 40 | 41 | if (nextMessage === null || canPush === false) { 42 | this[kReadableState].canPush = false; 43 | return; 44 | } 45 | } 46 | 47 | this.call.resume(); 48 | } 49 | 50 | deserialize (input) { 51 | if (input === null || input === undefined) { 52 | return null; 53 | } 54 | 55 | return this[kCall].handler.deserialize(input); 56 | } 57 | } 58 | 59 | ServerReadableStream.prototype.sendMetadata = sendMetadata; 60 | ServerReadableStream.prototype.getPeer = getPeer; 61 | ServerReadableStream.prototype.getDeadline = getDeadline; 62 | ServerReadableStream.prototype[kReadablePushOrBufferMessage] = 63 | readablePushOrBufferMessage; 64 | ServerReadableStream.prototype[kReadablePushMessage] = readablePushMessage; 65 | 66 | 67 | class ServerWritableStream extends Writable { 68 | constructor (call, metadata) { 69 | super({ objectMode: true }); 70 | setUpHandler(this, call, metadata); 71 | setUpWritable(this); 72 | this.request = undefined; 73 | } 74 | 75 | async _write (chunk, encoding, callback) { 76 | // This function is asynchronous in order to support async compression. 77 | // The following code does not work with `write()` being asynchronous, but 78 | // such code should be utilizing the `write()` callback. 79 | // stream.write(data); 80 | // stream.write(data); 81 | // stream.emit('error', err); 82 | try { 83 | const response = await this[kCall].serializeMessage(chunk); 84 | 85 | if (this[kCall].write(response) === false) { 86 | this[kCall].once('drain', callback); 87 | return; 88 | } 89 | 90 | callback(null); 91 | } catch (err) { 92 | err.code = Status.INTERNAL; 93 | callback(err); 94 | } 95 | } 96 | 97 | _final (callback) { 98 | this[kCall].end(); 99 | callback(null); 100 | } 101 | 102 | end (metadata) { 103 | if (metadata) { 104 | this[kCall].status.metadata = metadata; 105 | } 106 | 107 | Writable.prototype.end.call(this); 108 | } 109 | 110 | serialize (input) { 111 | if (input === null || input === undefined) { 112 | return null; 113 | } 114 | 115 | return this[kCall].handler.serialize(input); 116 | } 117 | } 118 | 119 | ServerWritableStream.prototype.sendMetadata = sendMetadata; 120 | ServerWritableStream.prototype.getPeer = getPeer; 121 | ServerWritableStream.prototype.getDeadline = getDeadline; 122 | 123 | 124 | class ServerDuplexStream extends Duplex { 125 | constructor (call, metadata) { 126 | super({ objectMode: true }); 127 | setUpHandler(this, call, metadata); 128 | setUpReadable(this); 129 | setUpWritable(this); 130 | } 131 | } 132 | 133 | ServerDuplexStream.prototype.sendMetadata = sendMetadata; 134 | ServerDuplexStream.prototype.getPeer = getPeer; 135 | ServerDuplexStream.prototype.getDeadline = getDeadline; 136 | ServerDuplexStream.prototype._read = ServerReadableStream.prototype._read; 137 | ServerDuplexStream.prototype._write = ServerWritableStream.prototype._write; 138 | ServerDuplexStream.prototype._final = ServerWritableStream.prototype._final; 139 | ServerDuplexStream.prototype.end = ServerWritableStream.prototype.end; 140 | ServerDuplexStream.prototype.serialize = 141 | ServerWritableStream.prototype.serialize; 142 | ServerDuplexStream.prototype.deserialize = 143 | ServerReadableStream.prototype.deserialize; 144 | ServerDuplexStream.prototype[kReadablePushOrBufferMessage] = 145 | ServerReadableStream.prototype[kReadablePushOrBufferMessage]; 146 | ServerDuplexStream.prototype[kReadablePushMessage] = 147 | ServerReadableStream.prototype[kReadablePushMessage]; 148 | 149 | 150 | function sendMetadata (responseMetadata) { 151 | return this[kCall].sendMetadata(responseMetadata); 152 | } 153 | 154 | 155 | function getPeer () { 156 | const { socket } = this.call.session; 157 | 158 | if (!(socket && socket.remoteAddress)) { 159 | return 'unknown'; 160 | } 161 | 162 | if (socket.remotePort) { 163 | return `${socket.remoteAddress}:${socket.remotePort}`; 164 | } 165 | 166 | return socket.remoteAddress; 167 | } 168 | 169 | 170 | function getDeadline () { 171 | return this[kCall].deadline; 172 | } 173 | 174 | 175 | function setUpHandler (handler, call, metadata) { 176 | handler[kCall] = call; 177 | handler.call = call.stream; 178 | handler.metadata = metadata; 179 | handler.cancelled = false; 180 | handler.cancelledReason = null; 181 | 182 | call.once('cancelled', (reason) => { 183 | handler.cancelled = true; 184 | handler.cancelledReason = reason; 185 | handler.emit('cancelled', reason); 186 | }); 187 | } 188 | 189 | 190 | function setUpReadable (stream) { 191 | const decoder = new StreamDecoder(); 192 | const { maxReceiveMessageLength } = stream[kCall]; 193 | 194 | stream[kReadableState] = { 195 | canPush: false, // Can data be pushed to the readable stream. 196 | isPushPending: false, // Is an asynchronous push operation in progress. 197 | bufferedMessages: [], // Messages that have not been deserialized yet. 198 | messagesToPush: [] // Deserialized messages not yet pushed to the stream. 199 | }; 200 | 201 | stream.once('cancelled', () => { 202 | stream.destroy(); 203 | }); 204 | 205 | stream.call.on('data', (data) => { 206 | // It's possible that more than one message arrives in a single 'data' 207 | // event. pushOrBufferMessage() ensures that only a single message is 208 | // actually processed at a time, because the deserialization process is 209 | // asynchronous, and can lead to out of order messages. 210 | const messages = decoder.write(data); 211 | 212 | for (let i = 0; i < messages.length; i++) { 213 | if (messages[i].length > maxReceiveMessageLength) { 214 | const err = new Error('Received message larger than max ' + 215 | `(${messages[i].length} vs. ${maxReceiveMessageLength})`); 216 | stream[kCall].sendError(err, Status.RESOURCE_EXHAUSTED); 217 | return; 218 | } 219 | 220 | stream[kReadablePushOrBufferMessage](messages[i]); 221 | } 222 | }); 223 | 224 | stream.call.once('end', () => { 225 | // If the HTTP2 stream was destroyed, an error happened so this stream 226 | // should not emit an 'end' event. 227 | if (stream.call.destroyed) { 228 | stream.destroy(); 229 | return; 230 | } 231 | 232 | stream[kReadablePushOrBufferMessage](null); 233 | }); 234 | } 235 | 236 | 237 | function readablePushOrBufferMessage (messageBytes) { 238 | const { bufferedMessages, isPushPending } = this[kReadableState]; 239 | 240 | if (isPushPending === true) { 241 | bufferedMessages.push(messageBytes); 242 | } else { 243 | this[kReadablePushMessage](messageBytes); 244 | } 245 | } 246 | 247 | 248 | async function readablePushMessage (messageBytes) { 249 | const { bufferedMessages, messagesToPush } = this[kReadableState]; 250 | 251 | if (messageBytes === null) { 252 | if (this[kReadableState].canPush === true) { 253 | this.push(null); 254 | } else { 255 | messagesToPush.push(null); 256 | } 257 | 258 | return; 259 | } 260 | 261 | this[kReadableState].isPushPending = true; 262 | 263 | try { 264 | const deserialized = await this[kCall].deserializeMessage(messageBytes); 265 | 266 | if (this[kReadableState].canPush === true) { 267 | if (!this.push(deserialized)) { 268 | this[kReadableState].canPush = false; 269 | this.call.pause(); 270 | } 271 | } else { 272 | messagesToPush.push(deserialized); 273 | } 274 | } catch (err) { 275 | // Ignore any remaining messages when errors occur. 276 | bufferedMessages.length = 0; 277 | 278 | if (!hasGrpcStatusCode(err)) { 279 | err.code = Status.INTERNAL; 280 | } 281 | 282 | this.emit('error', err); 283 | } 284 | 285 | this[kReadableState].isPushPending = false; 286 | 287 | if (bufferedMessages.length > 0) { 288 | this[kReadablePushMessage](bufferedMessages.shift()); 289 | } 290 | } 291 | 292 | 293 | function setUpWritable (stream) { 294 | stream.on('error', (err) => { 295 | stream[kCall].sendError(err); 296 | stream.destroy(); 297 | }); 298 | } 299 | 300 | 301 | module.exports = { 302 | ServerDuplexStream, 303 | ServerReadableStream, 304 | ServerUnaryCall, 305 | ServerWritableStream 306 | }; 307 | -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import * as http2 from 'http2'; 3 | import { Duplex, Readable, Writable } from 'stream'; 4 | 5 | 6 | export type Deadline = Date | number; 7 | 8 | 9 | export interface Serialize { 10 | (value: T): Buffer; 11 | } 12 | 13 | export interface Deserialize { 14 | (bytes: Buffer): T; 15 | } 16 | 17 | export interface MethodDefinition { 18 | path: string; 19 | requestStream: boolean; 20 | responseStream: boolean; 21 | requestSerialize: Serialize; 22 | responseSerialize: Serialize; 23 | requestDeserialize: Deserialize; 24 | responseDeserialize: Deserialize; 25 | originalName?: string; 26 | } 27 | 28 | 29 | export declare type KeyCertPair = { 30 | private_key: Buffer; 31 | cert_chain: Buffer; 32 | }; 33 | 34 | export declare abstract class ServerCredentials { 35 | abstract _isSecure(): boolean; 36 | abstract _getSettings(): http2.SecureServerOptions | null; 37 | static createInsecure(): ServerCredentials; 38 | static createSsl(rootCerts: Buffer | null, 39 | keyCertPairs: KeyCertPair[], 40 | checkClientCertificate?: boolean): ServerCredentials; 41 | } 42 | 43 | 44 | export interface MetadataOptions { 45 | // Signal that the request is idempotent. Defaults to false. 46 | idempotentRequest?: boolean; 47 | // Signal that the call should not return UNAVAILABLE before it has started. 48 | // Defaults to true. 49 | waitForReady?: boolean; 50 | // Signal that the call is cacheable. gRPC is free to use the GET verb. 51 | // Defaults to false. 52 | cacheableRequest?: boolean; 53 | // Signal that the initial metadata should be corked. Defaults to false. 54 | corked?: boolean; 55 | } 56 | 57 | export declare type MetadataValue = string | Buffer; 58 | export declare type MetadataObject = Map; 59 | export declare class Metadata { 60 | protected internalRepr: MetadataObject; 61 | private options: MetadataOptions; 62 | constructor(options?: MetadataOptions); 63 | set(key: string, value: MetadataValue): void; 64 | add(key: string, value: MetadataValue): void; 65 | remove(key: string): void; 66 | get(key: string): MetadataValue[]; 67 | getMap(): { [key: string]: MetadataValue; }; 68 | clone(): Metadata; 69 | merge(other: Metadata): void; 70 | toHttp2Headers(): http2.OutgoingHttpHeaders; 71 | setOptions(options: MetadataOptions): void; 72 | getOptions(): MetadataOptions; 73 | static fromHttp2Headers(headers: http2.IncomingHttpHeaders): Metadata; 74 | } 75 | 76 | 77 | export declare enum LogVerbosity { 78 | DEBUG = 0, 79 | INFO = 1, 80 | ERROR = 2 81 | } 82 | 83 | export declare const setLogger: (logger: Partial) => void; 84 | export declare const setLogVerbosity: (verbosity: LogVerbosity) => void; 85 | 86 | 87 | export declare enum Status { 88 | OK = 0, 89 | CANCELLED = 1, 90 | UNKNOWN = 2, 91 | INVALID_ARGUMENT = 3, 92 | DEADLINE_EXCEEDED = 4, 93 | NOT_FOUND = 5, 94 | ALREADY_EXISTS = 6, 95 | PERMISSION_DENIED = 7, 96 | RESOURCE_EXHAUSTED = 8, 97 | FAILED_PRECONDITION = 9, 98 | ABORTED = 10, 99 | OUT_OF_RANGE = 11, 100 | UNIMPLEMENTED = 12, 101 | INTERNAL = 13, 102 | UNAVAILABLE = 14, 103 | DATA_LOSS = 15, 104 | UNAUTHENTICATED = 16 105 | } 106 | 107 | export interface StatusObject { 108 | code: Status; 109 | details: string; 110 | metadata: Metadata; 111 | } 112 | 113 | export declare type ServiceError = StatusObject & Error; 114 | export declare type ServerStatusResponse = Partial; 115 | export declare type ServerErrorResponse = ServerStatusResponse & Error; 116 | 117 | 118 | declare type ServerSurfaceCall = { 119 | cancelled: boolean; 120 | readonly metadata: Metadata; 121 | getPeer(): string; 122 | sendMetadata(responseMetadata: Metadata): void; 123 | getDeadline(): Deadline; 124 | }; 125 | export declare type ServerUnaryCall = 126 | ServerSurfaceCall & { request: RequestType | null; }; 127 | export declare type ServerReadableStream = 128 | ServerSurfaceCall & Readable; 129 | export declare type ServerWritableStream = 130 | ServerSurfaceCall & Writable & { 131 | request: RequestType | null; 132 | end: (metadata?: Metadata) => void; 133 | }; 134 | export declare type ServerDuplexStream = 135 | ServerSurfaceCall & Duplex & { end: (metadata?: Metadata) => void; }; 136 | 137 | 138 | export declare type sendUnaryData = 139 | (error: ServerErrorResponse | ServerStatusResponse | null, 140 | value: ResponseType | null, 141 | trailer?: Metadata, 142 | flags?: number) => void; 143 | export declare type handleUnaryCall = 144 | (call: ServerUnaryCall, 145 | callback: sendUnaryData) => void; 146 | export declare type handleClientStreamingCall = 147 | (call: ServerReadableStream, 148 | callback: sendUnaryData) => void; 149 | export declare type handleServerStreamingCall = 150 | (call: ServerWritableStream) => void; 151 | export declare type handleBidiStreamingCall = 152 | (call: ServerDuplexStream) => void; 153 | 154 | 155 | export declare type HandleCall = 156 | handleUnaryCall | 157 | handleClientStreamingCall | 158 | handleServerStreamingCall | 159 | handleBidiStreamingCall; 160 | 161 | 162 | export declare type UntypedHandleCall = HandleCall; 163 | export interface UntypedServiceImplementation { 164 | [name: string]: UntypedHandleCall; 165 | } 166 | 167 | export declare type ServiceDefinition = { 168 | readonly [index in keyof ImplementationType]: MethodDefinition; 169 | } 170 | 171 | 172 | export interface ChannelOptions { 173 | 'grpc.http2.max_frame_size'?: string; 174 | 'grpc.ssl_target_name_override'?: string; 175 | 'grpc.primary_user_agent'?: string; 176 | 'grpc.secondary_user_agent'?: string; 177 | 'grpc.default_authority'?: string; 178 | 'grpc.keepalive_time_ms'?: number; 179 | 'grpc.keepalive_timeout_ms'?: number; 180 | 'grpc.service_config'?: string; 181 | 'grpc.max_concurrent_streams'?: number; 182 | 'grpc.initial_reconnect_backoff_ms'?: number; 183 | 'grpc.max_reconnect_backoff_ms'?: number; 184 | 'grpc.use_local_subchannel_pool'?: number; 185 | 'grpc.max_send_message_length'?: number; 186 | 'grpc.max_receive_message_length'?: number; 187 | [key: string]: string | number | undefined; 188 | } 189 | 190 | 191 | export declare class Server { 192 | constructor(options?: ChannelOptions); 193 | addProtoService(): void; 194 | addService(service: ServiceDefinition, 195 | implementation: UntypedServiceImplementation): void; 196 | removeService(service: ServiceDefinition): void; 197 | bind(port: string, creds: ServerCredentials): Promise; 198 | bindAsync(port: string, 199 | creds: ServerCredentials, 200 | callback: (error: Error | null, port: number) => void): void; 201 | forceShutdown(): void; 202 | register( 203 | name: string, 204 | handler: HandleCall, 205 | serialize: Serialize, 206 | deserialize: Deserialize, 207 | type: string 208 | ): boolean; 209 | unregister(name: string): boolean; 210 | start(): void; 211 | tryShutdown(callback: (error?: Error) => void): void; 212 | addHttp2Port(): void; 213 | } 214 | 215 | export { 216 | LogVerbosity as logVerbosity, 217 | Status as status 218 | }; 219 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { LogVerbosity, setLogger, setLogVerbosity } = require('./logging'); 3 | const { Metadata } = require('./metadata'); 4 | const { Server } = require('./server'); 5 | const { ServerCredentials } = require('./server-credentials'); 6 | const Status = require('./status'); 7 | 8 | 9 | module.exports = { 10 | logVerbosity: { ...LogVerbosity }, 11 | Metadata, 12 | Server, 13 | ServerCredentials, 14 | setLogger, 15 | setLogVerbosity, 16 | status: { ...Status } 17 | }; 18 | -------------------------------------------------------------------------------- /lib/logging.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const LogVerbosity = { 3 | DEBUG: 0, 4 | INFO: 1, 5 | ERROR: 2 6 | }; 7 | const envVerbosity = LogVerbosity[process.env.GRPC_VERBOSITY]; 8 | let _logger = console; 9 | let _logVerbosity = envVerbosity !== undefined ? envVerbosity : 10 | LogVerbosity.ERROR; 11 | 12 | 13 | function getLogger () { 14 | return _logger; 15 | } 16 | 17 | 18 | function setLogger (logger) { 19 | _logger = logger; 20 | } 21 | 22 | 23 | function setLogVerbosity (verbosity) { 24 | _logVerbosity = verbosity; 25 | } 26 | 27 | 28 | function log (severity, ...args) { 29 | if (severity >= _logVerbosity && typeof _logger.error === 'function') { 30 | _logger.error(...args); 31 | } 32 | } 33 | 34 | 35 | module.exports = { 36 | getLogger, 37 | log, 38 | LogVerbosity, 39 | setLogger, 40 | setLogVerbosity 41 | }; 42 | -------------------------------------------------------------------------------- /lib/metadata.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { log, LogVerbosity } = require('./logging'); 3 | const kLegalKeyRegex = /^[0-9a-z_.-]+$/; 4 | const kLegalNonBinaryValueRegex = /^[ -~]*$/; 5 | 6 | 7 | function isBinaryKey (key) { 8 | return key.endsWith('-bin'); 9 | } 10 | 11 | 12 | function isCustomMetadata (key) { 13 | return !key.startsWith('grpc-'); 14 | } 15 | 16 | 17 | function normalizeKey (key) { 18 | return key.toLowerCase(); 19 | } 20 | 21 | 22 | function validate (key, value = null) { 23 | if (!kLegalKeyRegex.test(key)) { 24 | throw new Error(`Metadata key "${key}" contains illegal characters`); 25 | } 26 | 27 | if (value === null) { 28 | return; 29 | } 30 | 31 | if (isBinaryKey(key)) { 32 | if (!(value instanceof Buffer)) { 33 | throw new Error('keys that end with \'-bin\' must have Buffer values'); 34 | } 35 | } else { 36 | if (value instanceof Buffer) { 37 | throw new Error( 38 | 'keys that don\'t end with \'-bin\' must have String values' 39 | ); 40 | } 41 | 42 | if (!kLegalNonBinaryValueRegex.test(value)) { 43 | throw new Error( 44 | `Metadata string value "${value}" contains illegal characters` 45 | ); 46 | } 47 | } 48 | } 49 | 50 | 51 | class Metadata { 52 | constructor (options = {}) { 53 | this.options = options; 54 | this.internalRepr = new Map(); 55 | } 56 | 57 | set (key, value) { 58 | key = normalizeKey(key); 59 | validate(key, value); 60 | this.internalRepr.set(key, [value]); 61 | } 62 | 63 | add (key, value) { 64 | key = normalizeKey(key); 65 | validate(key, value); 66 | 67 | const existingValue = this.internalRepr.get(key); 68 | 69 | if (existingValue === undefined) { 70 | this.internalRepr.set(key, [value]); 71 | } else { 72 | existingValue.push(value); 73 | } 74 | } 75 | 76 | remove (key) { 77 | key = normalizeKey(key); 78 | validate(key); 79 | this.internalRepr.delete(key); 80 | } 81 | 82 | get (key) { 83 | key = normalizeKey(key); 84 | validate(key); 85 | return this.internalRepr.get(key) || []; 86 | } 87 | 88 | getMap () { 89 | const result = {}; 90 | 91 | this.internalRepr.forEach((values, key) => { 92 | if (values.length > 0) { 93 | const v = values[0]; 94 | 95 | result[key] = v instanceof Buffer ? v.slice() : v; 96 | } 97 | }); 98 | 99 | return result; 100 | } 101 | 102 | clone () { 103 | const newMetadata = new Metadata(this.options); 104 | const newInternalRepr = newMetadata.internalRepr; 105 | 106 | this.internalRepr.forEach((value, key) => { 107 | const clonedValue = value.map((v) => { 108 | return v instanceof Buffer ? Buffer.from(v) : v; 109 | }); 110 | 111 | newInternalRepr.set(key, clonedValue); 112 | }); 113 | 114 | return newMetadata; 115 | } 116 | 117 | merge (other) { 118 | other.internalRepr.forEach((values, key) => { 119 | const mergedValue = (this.internalRepr.get(key) || []).concat(values); 120 | 121 | this.internalRepr.set(key, mergedValue); 122 | }); 123 | } 124 | 125 | setOptions (options) { 126 | this.options = options; 127 | } 128 | 129 | getOptions () { 130 | return this.options; 131 | } 132 | 133 | toHttp2Headers () { 134 | const result = {}; 135 | 136 | this.internalRepr.forEach((values, key) => { 137 | result[key] = values.map((value) => { 138 | return value instanceof Buffer ? value.toString('base64') : value; 139 | }); 140 | }); 141 | 142 | return result; 143 | } 144 | 145 | static fromHttp2Headers (headers) { 146 | const result = new Metadata(); 147 | 148 | Object.keys(headers).forEach((key) => { 149 | // Reserved headers (beginning with `:`) are not valid keys. 150 | if (key.charAt(0) === ':') { 151 | return; 152 | } 153 | 154 | const values = headers[key]; 155 | 156 | try { 157 | if (isBinaryKey(key)) { 158 | if (Array.isArray(values)) { 159 | values.forEach((value) => { 160 | result.add(key, Buffer.from(value, 'base64')); 161 | }); 162 | } else if (values !== undefined) { 163 | if (isCustomMetadata(key)) { 164 | values.split(',').forEach((v) => { 165 | result.add(key, Buffer.from(v.trim(), 'base64')); 166 | }); 167 | } else { 168 | result.add(key, Buffer.from(values, 'base64')); 169 | } 170 | } 171 | } else { 172 | if (Array.isArray(values)) { 173 | values.forEach((value) => { 174 | result.add(key, value); 175 | }); 176 | } else if (values !== undefined) { 177 | result.add(key, values); 178 | } 179 | } 180 | } catch (err) { 181 | log( 182 | LogVerbosity.ERROR, 183 | `Failed to add metadata entry ${key}: ${values}. ${err.message}.` 184 | ); 185 | } 186 | }); 187 | 188 | return result; 189 | } 190 | } 191 | 192 | module.exports = { Metadata }; 193 | -------------------------------------------------------------------------------- /lib/options.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Http2 = require('http2'); 3 | const defaultHttp2Settings = Http2.getDefaultSettings(); 4 | const defaultServerOptions = { 5 | 'grpc.max_concurrent_streams': undefined, 6 | 'grpc.http2.max_frame_size': defaultHttp2Settings.maxFrameSize, 7 | 'grpc.keepalive_time_ms': 7200000, // 2 hours in ms (spec default). 8 | 'grpc.keepalive_timeout_ms': 20000, // 20 seconds in ms (spec default). 9 | 'grpc.max_send_message_length': Infinity, 10 | 'grpc.max_receive_message_length': 4 * 1024 * 1024 // 4 MB 11 | }; 12 | 13 | 14 | function parseOptions (inputOptions) { 15 | const mergedOptions = { ...defaultServerOptions, ...inputOptions }; 16 | 17 | // Check for unsupported options. 18 | for (const prop in mergedOptions) { 19 | if (!(prop in defaultServerOptions)) { 20 | throw new Error(`unknown option: ${prop}`); 21 | } 22 | } 23 | 24 | // Map the gRPC option names to normal camelCase property names. 25 | const options = { 26 | maxConcurrentStreams: mergedOptions['grpc.max_concurrent_streams'], 27 | maxFrameSize: mergedOptions['grpc.http2.max_frame_size'], 28 | keepaliveTimeMs: mergedOptions['grpc.keepalive_time_ms'], 29 | keepaliveTimeoutMs: mergedOptions['grpc.keepalive_timeout_ms'], 30 | maxSendMessageLength: mergedOptions['grpc.max_send_message_length'], 31 | maxReceiveMessageLength: mergedOptions['grpc.max_receive_message_length'] 32 | }; 33 | 34 | // grpc.max_send_message_length uses -1 to represent no max size. 35 | if (options.maxSendMessageLength === -1) { 36 | options.maxSendMessageLength = Infinity; 37 | } 38 | 39 | // grpc.max_receive_message_length uses -1 to represent no max size. 40 | if (options.maxReceiveMessageLength === -1) { 41 | options.maxReceiveMessageLength = Infinity; 42 | } 43 | 44 | return options; 45 | } 46 | 47 | module.exports = { parseOptions }; 48 | -------------------------------------------------------------------------------- /lib/server-call.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const EventEmitter = require('events'); 3 | const Http2 = require('http2'); 4 | const { CompressionFilter } = require('./compression-filter'); 5 | const { Metadata } = require('./metadata'); 6 | const Status = require('./status'); 7 | const kGrpcMessageHeader = 'grpc-message'; 8 | const kGrpcStatusHeader = 'grpc-status'; 9 | const kGrpcTimeoutHeader = 'grpc-timeout'; 10 | const kGrpcEncodingHeader = 'grpc-encoding'; 11 | const kGrpcAcceptEncodingHeader = 'grpc-accept-encoding'; 12 | const kDeadlineRegex = /(\d{1,8})\s*([HMSmun])/; 13 | const deadlineUnitsToMs = { 14 | H: 3600000, 15 | M: 60000, 16 | S: 1000, 17 | m: 1, 18 | u: 0.001, 19 | n: 0.000001 20 | }; 21 | const defaultResponseOptions = { waitForTrailers: true }; 22 | const { 23 | HTTP2_HEADER_CONTENT_TYPE, 24 | HTTP2_HEADER_STATUS, 25 | HTTP_STATUS_OK, 26 | NGHTTP2_CANCEL 27 | } = Http2.constants; 28 | 29 | 30 | class ServerCall extends EventEmitter { 31 | constructor (stream, options) { 32 | super(); 33 | this.handler = null; 34 | this.stream = stream; 35 | this.cancelled = false; 36 | this.deadline = Infinity; 37 | this.deadlineTimer = null; 38 | this.compression = new CompressionFilter(); 39 | this.metadataSent = false; 40 | this.status = { code: Status.OK, details: 'OK', metadata: null }; 41 | this.maxSendMessageLength = options.maxSendMessageLength; 42 | this.maxReceiveMessageLength = options.maxReceiveMessageLength; 43 | this.stream.on('drain', onStreamDrain.bind(this)); 44 | this.stream.once('error', onStreamError.bind(this)); 45 | this.stream.once('close', onStreamClose.bind(this)); 46 | } 47 | 48 | sendMetadata (customMetadata) { 49 | if (this.metadataSent === true || this.cancelled === true || 50 | this.stream.destroyed === true) { 51 | return; 52 | } 53 | 54 | this.metadataSent = true; 55 | 56 | const headers = { 57 | [kGrpcEncodingHeader]: this.compression.send.name, 58 | [kGrpcAcceptEncodingHeader]: this.compression.accepts.join(','), 59 | [HTTP2_HEADER_STATUS]: HTTP_STATUS_OK, 60 | [HTTP2_HEADER_CONTENT_TYPE]: 'application/grpc+proto' 61 | }; 62 | 63 | this.stream.once('wantTrailers', onWantTrailers.bind(this)); 64 | 65 | if (customMetadata === undefined || customMetadata === null) { 66 | this.stream.respond(headers, defaultResponseOptions); 67 | } else { 68 | this.stream.respond({ 69 | ...headers, 70 | ...customMetadata.toHttp2Headers() 71 | }, defaultResponseOptions); 72 | } 73 | } 74 | 75 | receiveMetadata (headers) { 76 | let metadata = Metadata.fromHttp2Headers(headers); 77 | 78 | metadata = this.compression.receiveMetadata(metadata); 79 | 80 | const timeoutHeader = metadata.get(kGrpcTimeoutHeader); 81 | 82 | if (timeoutHeader.length > 0) { 83 | const match = timeoutHeader[0].match(kDeadlineRegex); 84 | 85 | if (match === null) { 86 | this.sendError(new Error('Invalid deadline'), Status.OUT_OF_RANGE); 87 | return; 88 | } 89 | 90 | const timeout = (+match[1] * deadlineUnitsToMs[match[2]]) | 0; 91 | 92 | this.deadline = Date.now() + timeout; 93 | this.deadlineTimer = setTimeout(handleExpiredDeadline, timeout, this); 94 | metadata.remove(kGrpcTimeoutHeader); 95 | } 96 | 97 | return metadata; 98 | } 99 | 100 | receiveUnaryMessage (callback) { 101 | const stream = this.stream; 102 | const chunks = []; 103 | let totalLength = 0; 104 | 105 | stream.on('data', (data) => { 106 | chunks.push(data); 107 | totalLength += data.byteLength; 108 | }); 109 | 110 | stream.once('end', async () => { 111 | if (totalLength > this.maxReceiveMessageLength) { 112 | const err = new Error('Received message larger than max ' + 113 | `(${totalLength} vs. ${this.maxReceiveMessageLength})`); 114 | this.sendError(err, Status.RESOURCE_EXHAUSTED); 115 | return; 116 | } 117 | 118 | try { 119 | const requestBytes = Buffer.concat(chunks, totalLength); 120 | 121 | callback(null, await this.deserializeMessage(requestBytes)); 122 | } catch (err) { 123 | this.sendError(err, Status.INTERNAL); 124 | callback(err, null); 125 | } 126 | }); 127 | } 128 | 129 | serializeMessage (value) { 130 | const messageBuffer = this.handler.serialize(value); 131 | 132 | return this.compression.serializeMessage(messageBuffer); 133 | } 134 | 135 | async deserializeMessage (bytes) { 136 | const receivedMessage = await this.compression.deserializeMessage(bytes); 137 | 138 | return this.handler.deserialize(receivedMessage); 139 | } 140 | 141 | async sendUnaryMessage (err, value, metadata, flags) { 142 | if (err) { 143 | if (metadata && !err.hasOwnProperty('metadata')) { 144 | err.metadata = metadata; 145 | } 146 | 147 | this.sendError(err); 148 | return; 149 | } 150 | 151 | try { 152 | const response = await this.serializeMessage(value); 153 | 154 | if (metadata) { 155 | this.status.metadata = metadata; 156 | } 157 | 158 | this.write(response); 159 | this.stream.end(); 160 | } catch (err) { 161 | this.sendError(err, Status.INTERNAL); 162 | } 163 | } 164 | 165 | sendError (error, code = Status.UNKNOWN) { 166 | const { status } = this; 167 | 168 | if ('message' in error) { 169 | status.details = error.message; 170 | } else { 171 | status.details = 'Unknown Error'; 172 | } 173 | 174 | if ('code' in error && Number.isInteger(error.code)) { 175 | status.code = error.code; 176 | 177 | if ('details' in error && typeof error.details === 'string') { 178 | status.details = error.details; 179 | } 180 | } else { 181 | status.code = code; 182 | } 183 | 184 | if ('metadata' in error && error.metadata !== undefined) { 185 | status.metadata = error.metadata; 186 | } 187 | 188 | this.end(); 189 | } 190 | 191 | write (chunk) { 192 | if (this.cancelled === true || this.stream.destroyed === true) { 193 | return; 194 | } 195 | 196 | if (chunk.length > this.maxSendMessageLength) { 197 | const err = new Error('Sent message larger than max ' + 198 | `(${chunk.length} vs. ${this.maxSendMessageLength})`); 199 | this.sendError(err, Status.RESOURCE_EXHAUSTED); 200 | return; 201 | } 202 | 203 | this.sendMetadata(); 204 | return this.stream.write(chunk); 205 | } 206 | 207 | end () { 208 | if (this.cancelled === true || this.stream.destroyed === true) { 209 | return; 210 | } 211 | 212 | this.sendMetadata(); 213 | return this.stream.end(); 214 | } 215 | } 216 | 217 | module.exports = { ServerCall }; 218 | 219 | 220 | function onStreamDrain () { 221 | // `this` is bound to the Call instance, not the stream itself. 222 | this.emit('drain'); 223 | } 224 | 225 | function onStreamError (err) { 226 | // `this` is bound to the Call instance, not the stream itself. 227 | this.sendError(err, Status.INTERNAL); 228 | } 229 | 230 | 231 | function onStreamClose () { 232 | // `this` is bound to the Call instance, not the stream itself. 233 | if (this.stream.rstCode === NGHTTP2_CANCEL) { 234 | this.cancelled = true; 235 | this.emit('cancelled', 'cancelled'); 236 | } 237 | } 238 | 239 | 240 | function onWantTrailers () { 241 | // `this` is bound to the Call instance, not the stream itself. 242 | let trailersToSend = { 243 | [kGrpcStatusHeader]: this.status.code, 244 | [kGrpcMessageHeader]: encodeURI(this.status.details) 245 | }; 246 | const metadata = this.status.metadata; 247 | 248 | if (this.status.metadata !== null) { 249 | trailersToSend = { ...trailersToSend, ...metadata.toHttp2Headers() }; 250 | } 251 | 252 | clearTimeout(this.deadlineTimer); 253 | this.stream.sendTrailers(trailersToSend); 254 | } 255 | 256 | 257 | function handleExpiredDeadline (call) { 258 | call.sendError(new Error('Deadline exceeded'), Status.DEADLINE_EXCEEDED); 259 | call.cancelled = true; 260 | call.emit('cancelled', 'deadline'); 261 | } 262 | -------------------------------------------------------------------------------- /lib/server-credentials.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { readFileSync } = require('fs'); 3 | const cipherSuites = process.env.GRPC_SSL_CIPHER_SUITES; 4 | const defaultRootsFilePath = process.env.GRPC_DEFAULT_SSL_ROOTS_FILE_PATH; 5 | let defaultRootsData = null; 6 | 7 | 8 | class InsecureServerCredentials { 9 | _isSecure () { // eslint-disable-line class-methods-use-this 10 | return false; 11 | } 12 | 13 | _getSettings () { // eslint-disable-line class-methods-use-this 14 | return null; 15 | } 16 | } 17 | 18 | 19 | class SecureServerCredentials { 20 | constructor (options = {}) { 21 | this.options = options; 22 | } 23 | 24 | _isSecure () { // eslint-disable-line class-methods-use-this 25 | return true; 26 | } 27 | 28 | _getSettings () { 29 | return this.options; 30 | } 31 | } 32 | 33 | 34 | class ServerCredentials { 35 | static createInsecure () { 36 | return new InsecureServerCredentials(); 37 | } 38 | 39 | static createSsl (rootCerts, keyCertPairs, checkClientCertificate = false) { 40 | if (rootCerts !== null && !Buffer.isBuffer(rootCerts)) { 41 | throw new TypeError('rootCerts must be null or a Buffer'); 42 | } 43 | 44 | if (!Array.isArray(keyCertPairs)) { 45 | throw new TypeError('keyCertPairs must be an array'); 46 | } 47 | 48 | if (typeof checkClientCertificate !== 'boolean') { 49 | throw new TypeError('checkClientCertificate must be a boolean'); 50 | } 51 | 52 | const cert = []; 53 | const key = []; 54 | 55 | for (let i = 0; i < keyCertPairs.length; i++) { 56 | const pair = keyCertPairs[i]; 57 | 58 | if (pair === null || typeof pair !== 'object') { 59 | throw new TypeError(`keyCertPair[${i}] must be an object`); 60 | } 61 | 62 | if (!Buffer.isBuffer(pair.private_key)) { 63 | throw new TypeError(`keyCertPair[${i}].private_key must be a Buffer`); 64 | } 65 | 66 | if (!Buffer.isBuffer(pair.cert_chain)) { 67 | throw new TypeError(`keyCertPair[${i}].cert_chain must be a Buffer`); 68 | } 69 | 70 | cert.push(pair.cert_chain); 71 | key.push(pair.private_key); 72 | } 73 | 74 | return new SecureServerCredentials({ 75 | ca: rootCerts || getDefaultRootsData() || undefined, 76 | cert, 77 | key, 78 | requestCert: checkClientCertificate, 79 | ciphers: cipherSuites 80 | }); 81 | } 82 | } 83 | 84 | module.exports = { ServerCredentials }; 85 | 86 | 87 | function getDefaultRootsData () { 88 | if (!defaultRootsFilePath) { 89 | return null; 90 | } 91 | 92 | if (defaultRootsData === null) { 93 | defaultRootsData = readFileSync(defaultRootsFilePath); 94 | } 95 | 96 | return defaultRootsData; 97 | } 98 | -------------------------------------------------------------------------------- /lib/server-resolver.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { isAbsolute, resolve } = require('path'); 3 | const { URL } = require('url'); 4 | 5 | 6 | function resolveToListenOptions (target, secure) { 7 | if (target.startsWith('unix:')) { 8 | if (target.startsWith('unix://')) { 9 | const path = target.substring(7); 10 | 11 | // The path following 'unix://' must be absolute. 12 | if (!isAbsolute(path)) { 13 | throw new Error(`'${target}' must specify an absolute path`); 14 | } 15 | 16 | return { path }; 17 | } 18 | 19 | // The path following 'unix:' can be relative or absolute. 20 | return { path: resolve(target.substring(5)) }; 21 | } 22 | 23 | if (target.startsWith('dns:')) { 24 | target = target.substring(4); 25 | } 26 | 27 | const url = new URL(`http://${target}`); 28 | const defaultPort = secure === true ? 443 : 80; 29 | let port = String(+url.port) === url.port ? +url.port : defaultPort; 30 | 31 | // Handle an edge case. WHATWG URLs don't set their port to 80, so a manual 32 | // check is required here. 33 | if (secure && url.port === '' && target.includes(`${url.hostname}:80`)) { 34 | port = 80; 35 | } 36 | 37 | return { host: url.hostname, port }; 38 | } 39 | 40 | 41 | module.exports = { resolveToListenOptions }; 42 | -------------------------------------------------------------------------------- /lib/server-session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const EventEmitter = require('events'); 3 | 4 | class ServerSession extends EventEmitter { 5 | constructor (http2Session, options) { 6 | super(); 7 | this.http2Session = http2Session; 8 | this.options = options; 9 | this.keepaliveInterval = null; 10 | this.keepaliveTimeout = null; 11 | 12 | const teardown = onSessionClose.bind(this); 13 | this.http2Session.on('close', teardown); 14 | this.http2Session.on('error', teardown); 15 | } 16 | 17 | startKeepalivePings () { 18 | const sendPing = this.sendPing.bind(this); 19 | const intervalLength = this.options.keepaliveTimeMs; 20 | 21 | this.keepaliveInterval = setInterval(sendPing, intervalLength); 22 | } 23 | 24 | stopKeepalivePings () { 25 | clearInterval(this.keepaliveInterval); 26 | clearTimeout(this.keepaliveTimeout); 27 | this.keepaliveInterval = null; 28 | this.keepaliveTimeout = null; 29 | } 30 | 31 | sendPing () { 32 | this.keepaliveTimeout = setTimeout(() => { 33 | // The ping timed out. 34 | this.stopKeepalivePings(); 35 | this.http2Session.destroy(); 36 | }, this.options.keepaliveTimeoutMs); 37 | 38 | this.http2Session.ping((err, duration, payload) => { 39 | clearTimeout(this.keepaliveTimeout); 40 | 41 | if (err) { 42 | // The ping errored. 43 | this.stopKeepalivePings(); 44 | this.http2Session.destroy(); 45 | } 46 | }); 47 | } 48 | } 49 | 50 | module.exports = { ServerSession }; 51 | 52 | 53 | function onSessionClose () { 54 | this.stopKeepalivePings(); 55 | this.emit('close'); 56 | } 57 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Http2 = require('http2'); 3 | const { 4 | ServerDuplexStream, 5 | ServerReadableStream, 6 | ServerUnaryCall, 7 | ServerWritableStream 8 | } = require('./handler'); 9 | const { parseOptions } = require('./options'); 10 | const { ServerCall } = require('./server-call'); 11 | const { ServerCredentials } = require('./server-credentials'); 12 | const { resolveToListenOptions } = require('./server-resolver'); 13 | const { ServerSession } = require('./server-session'); 14 | const Status = require('./status'); 15 | const kHandlers = Symbol('handlers'); 16 | const kServers = Symbol('servers'); 17 | const kStarted = Symbol('started'); 18 | const kOptions = Symbol('options'); 19 | const kSessions = Symbol('sessions'); 20 | const kUnaryHandlerType = 0; 21 | const kClientStreamHandlerType = 1; 22 | const kServerStreamHandlerType = 2; 23 | const kBidiHandlerType = 3; 24 | const kValidContentTypePrefix = 'application/grpc'; 25 | const { 26 | HTTP2_HEADER_CONTENT_TYPE, 27 | HTTP2_HEADER_STATUS, 28 | HTTP2_HEADER_PATH, 29 | HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, 30 | NGHTTP2_CANCEL 31 | } = Http2.constants; 32 | const defaultHttp2Settings = { 33 | ...Http2.getDefaultSettings(), 34 | maxSendHeaderBlockLength: Number.MAX_SAFE_INTEGER 35 | }; 36 | 37 | const unsuportedMediaTypeResponse = { 38 | [HTTP2_HEADER_STATUS]: HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE 39 | }; 40 | const unsuportedMediaTypeResponseOptions = { endStream: true }; 41 | 42 | function noop () {} 43 | 44 | 45 | class Server { 46 | constructor (options = {}) { 47 | if (options === null || typeof options !== 'object') { 48 | throw new TypeError('options must be an object'); 49 | } 50 | 51 | this[kServers] = []; 52 | this[kHandlers] = new Map(); 53 | this[kSessions] = new Set(); 54 | this[kStarted] = false; 55 | this[kOptions] = parseOptions(options); 56 | } 57 | 58 | bind (port, creds) { 59 | if (this[kStarted] === true) { 60 | throw new Error('server is already started'); 61 | } 62 | 63 | if (typeof port === 'number') { 64 | port = `localhost:${port}`; 65 | } 66 | 67 | if (creds === null || typeof creds !== 'object') { 68 | creds = ServerCredentials.createInsecure(); 69 | } 70 | 71 | return new Promise((resolve, reject) => { 72 | this.bindAsync(port, creds, (err, boundPort) => { 73 | if (err) { 74 | reject(err); 75 | } 76 | 77 | resolve(boundPort); 78 | }); 79 | }); 80 | } 81 | 82 | bindAsync (port, creds, callback) { 83 | if (this[kStarted] === true) { 84 | throw new Error('server is already started'); 85 | } 86 | 87 | if (typeof port !== 'string') { 88 | throw new TypeError('port must be a string'); 89 | } 90 | 91 | if (creds === null || typeof creds !== 'object') { 92 | throw new TypeError('creds must be an object'); 93 | } 94 | 95 | if (typeof callback !== 'function') { 96 | throw new TypeError('callback must be a function'); 97 | } 98 | 99 | const listenOptions = resolveToListenOptions(port, creds._isSecure()); 100 | const http2ServerOptions = { 101 | allowHTTP1: false, 102 | settings: { 103 | ...defaultHttp2Settings, 104 | enablePush: false, 105 | maxFrameSize: this[kOptions].maxFrameSize, 106 | maxConcurrentStreams: this[kOptions].maxConcurrentStreams 107 | } 108 | }; 109 | 110 | let server; 111 | 112 | if (creds._isSecure()) { 113 | server = Http2.createSecureServer({ 114 | ...http2ServerOptions, 115 | ...creds._getSettings() 116 | }); 117 | } else { 118 | server = Http2.createServer(http2ServerOptions); 119 | } 120 | 121 | server.timeout = 0; 122 | setupHandlers(this, server); 123 | 124 | function onError (err) { 125 | callback(err, -1); 126 | } 127 | 128 | server.once('error', onError); 129 | server.listen(listenOptions, () => { 130 | const port = server.address().port; 131 | 132 | server.removeListener('error', onError); 133 | this[kServers].push(server); 134 | callback(null, port); 135 | }); 136 | } 137 | 138 | start () { 139 | const servers = this[kServers]; 140 | const ready = servers.length > 0 && servers.every((server) => { 141 | return server.listening === true; 142 | }); 143 | 144 | if (!ready) { 145 | throw new Error('server must be bound in order to start'); 146 | } 147 | 148 | if (this[kStarted] === true) { 149 | throw new Error('server is already started'); 150 | } 151 | 152 | this[kStarted] = true; 153 | } 154 | 155 | addService (service, implementation) { 156 | if (service === null || typeof service !== 'object' || 157 | implementation === null || typeof implementation !== 'object') { 158 | throw new Error('addService requires two objects as arguments'); 159 | } 160 | 161 | const serviceKeys = Object.keys(service); 162 | 163 | if (serviceKeys.length === 0) { 164 | throw new Error('Cannot add an empty service to a server'); 165 | } 166 | 167 | serviceKeys.forEach((name) => { 168 | const attrs = service[name]; 169 | let methodType; 170 | 171 | if (attrs.requestStream) { 172 | if (attrs.responseStream) { 173 | methodType = kBidiHandlerType; 174 | } else { 175 | methodType = kClientStreamHandlerType; 176 | } 177 | } else { 178 | if (attrs.responseStream) { 179 | methodType = kServerStreamHandlerType; 180 | } else { 181 | methodType = kUnaryHandlerType; 182 | } 183 | } 184 | 185 | const implFn = implementation[name] || implementation[attrs.originalName]; 186 | let impl; 187 | 188 | if (implFn !== undefined) { 189 | impl = implFn.bind(implementation); 190 | } else { 191 | impl = getDefaultHandler(methodType, name); 192 | } 193 | 194 | const success = this.register(attrs.path, impl, attrs.responseSerialize, 195 | attrs.requestDeserialize, methodType); 196 | 197 | if (success === false) { 198 | throw new Error(`Method handler for ${attrs.path} already provided.`); 199 | } 200 | }); 201 | } 202 | 203 | removeService (service) { 204 | if (service === null || typeof service !== 'object') { 205 | throw new Error('removeService requires an object argument'); 206 | } 207 | 208 | Object.keys(service).forEach((name) => { 209 | this.unregister(service[name].path); 210 | }); 211 | } 212 | 213 | register (name, handler, serialize, deserialize, type) { 214 | if (this[kHandlers].has(name)) { 215 | return false; 216 | } 217 | 218 | this[kHandlers].set(name, { 219 | func: handler, 220 | serialize, 221 | deserialize, 222 | type, 223 | path: name 224 | }); 225 | 226 | return true; 227 | } 228 | 229 | unregister (name) { 230 | return this[kHandlers].delete(name); 231 | } 232 | 233 | tryShutdown (callback) { 234 | callback = typeof callback === 'function' ? callback : noop; 235 | 236 | let pendingChecks = 0; 237 | let callbackError = null; 238 | 239 | function maybeCallback (err) { 240 | if (err) { 241 | callbackError = err; 242 | } 243 | 244 | pendingChecks--; 245 | 246 | if (pendingChecks === 0) { 247 | callback(callbackError); 248 | } 249 | } 250 | 251 | // Close the server if necessary. 252 | this[kStarted] = false; 253 | this[kServers].forEach((server) => { 254 | if (server.listening === true) { 255 | pendingChecks++; 256 | server.close(maybeCallback); 257 | } 258 | }); 259 | 260 | // If any sessions are active, close them gracefully. 261 | this[kSessions].forEach((session) => { 262 | if (!session.closed) { 263 | session.close(maybeCallback); 264 | pendingChecks++; 265 | } 266 | }); 267 | 268 | // If the server is closed and there are no active sessions, just call back. 269 | if (pendingChecks === 0) { 270 | callback(null); 271 | } 272 | } 273 | 274 | forceShutdown () { 275 | // Close the server if it is still running. 276 | this[kServers].forEach((server) => { 277 | if (server.listening === true) { 278 | server.close(); 279 | } 280 | }); 281 | 282 | this[kStarted] = false; 283 | 284 | // Always destroy any available sessions. It's possible that one or more 285 | // tryShutdown() calls are in progress. Don't wait on them to finish. 286 | this[kSessions].forEach((session) => { 287 | session.destroy(NGHTTP2_CANCEL); 288 | }); 289 | 290 | this[kSessions].clear(); 291 | } 292 | 293 | addHttp2Port () { // eslint-disable-line class-methods-use-this 294 | throw new Error('not implemented'); 295 | } 296 | 297 | addProtoService () { // eslint-disable-line class-methods-use-this 298 | throw new Error('not implemented. use addService() instead'); 299 | } 300 | } 301 | 302 | module.exports = { Server }; 303 | 304 | 305 | const handlerTypes = [ 306 | handleUnary, 307 | handleClientStreaming, 308 | handleServerStreaming, 309 | handleBidiStreaming 310 | ]; 311 | 312 | 313 | function setupHandlers (grpcServer, http2Server) { 314 | http2Server.on('stream', (stream, headers) => { 315 | const contentType = headers[HTTP2_HEADER_CONTENT_TYPE]; 316 | 317 | if (typeof contentType !== 'string' || 318 | !contentType.startsWith(kValidContentTypePrefix)) { 319 | stream.respond(unsuportedMediaTypeResponse, 320 | unsuportedMediaTypeResponseOptions); 321 | return; 322 | } 323 | 324 | const call = new ServerCall(stream, grpcServer[kOptions]); 325 | 326 | try { 327 | const path = headers[HTTP2_HEADER_PATH]; 328 | const handler = grpcServer[kHandlers].get(path); 329 | 330 | if (handler === undefined) { 331 | return call.sendError(getUnimplementedStatusResponse(path)); 332 | } 333 | 334 | const metadata = call.receiveMetadata(headers); 335 | 336 | call.handler = handler; 337 | handlerTypes[handler.type](call, handler, metadata); 338 | } catch (err) { 339 | call.sendError(err, Status.INTERNAL); 340 | } 341 | }); 342 | 343 | http2Server.on('session', (session) => { 344 | if (grpcServer[kStarted] !== true) { 345 | session.destroy(); 346 | return; 347 | } 348 | 349 | const grpcSession = new ServerSession(session, grpcServer[kOptions]); 350 | 351 | // The client has connected, so begin sending keepalive pings. 352 | grpcSession.startKeepalivePings(); 353 | 354 | grpcServer[kSessions].add(session); 355 | grpcSession.once('close', () => { 356 | grpcServer[kSessions].delete(session); 357 | }); 358 | }); 359 | } 360 | 361 | 362 | function handleUnary (call, handler, metadata) { 363 | call.receiveUnaryMessage((err, request) => { 364 | if (err !== null || call.cancelled === true) { 365 | return; 366 | } 367 | 368 | const emitter = new ServerUnaryCall(call, metadata); 369 | 370 | emitter.request = request; 371 | handler.func(emitter, call.sendUnaryMessage.bind(call)); 372 | }); 373 | } 374 | 375 | 376 | function handleClientStreaming (call, handler, metadata) { 377 | const stream = new ServerReadableStream(call, metadata); 378 | 379 | function respond (err, value, trailer, flags) { 380 | stream.destroy(); 381 | call.sendUnaryMessage(err, value, trailer, flags); 382 | } 383 | 384 | if (call.cancelled === true) { 385 | return; 386 | } 387 | 388 | stream.on('error', respond); 389 | handler.func(stream, respond); 390 | } 391 | 392 | 393 | function handleServerStreaming (call, handler, metadata) { 394 | call.receiveUnaryMessage((err, request) => { 395 | if (err !== null || call.cancelled === true) { 396 | return; 397 | } 398 | 399 | const stream = new ServerWritableStream(call, metadata); 400 | 401 | stream.request = request; 402 | handler.func(stream); 403 | }); 404 | } 405 | 406 | 407 | function handleBidiStreaming (call, handler, metadata) { 408 | const stream = new ServerDuplexStream(call, metadata); 409 | 410 | if (call.cancelled === true) { 411 | return; 412 | } 413 | 414 | handler.func(stream); 415 | } 416 | 417 | 418 | function getUnimplementedStatusResponse (path) { 419 | return { 420 | code: Status.UNIMPLEMENTED, 421 | details: `The server does not implement the method ${path}` 422 | }; 423 | } 424 | 425 | 426 | function getDefaultHandler (handlerType, callName) { 427 | const unimplementedStatusResponse = getUnimplementedStatusResponse(callName); 428 | 429 | switch (handlerType) { 430 | case 0 : // Unary 431 | return function unary (call, callback) { 432 | callback(unimplementedStatusResponse); 433 | }; 434 | case 1 : // Client stream 435 | return function clientStream (call, callback) { 436 | callback(unimplementedStatusResponse); 437 | }; 438 | case 2 : // Server stream 439 | return function serverStream (call) { 440 | call.emit('error', unimplementedStatusResponse); 441 | }; 442 | case 3 : // Bidi stream 443 | return function bidi (call) { 444 | call.emit('error', unimplementedStatusResponse); 445 | }; 446 | default : 447 | throw new Error(`Invalid handler type ${handlerType}`); 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /lib/status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | OK: 0, 5 | CANCELLED: 1, 6 | UNKNOWN: 2, 7 | INVALID_ARGUMENT: 3, 8 | DEADLINE_EXCEEDED: 4, 9 | NOT_FOUND: 5, 10 | ALREADY_EXISTS: 6, 11 | PERMISSION_DENIED: 7, 12 | RESOURCE_EXHAUSTED: 8, 13 | FAILED_PRECONDITION: 9, 14 | ABORTED: 10, 15 | OUT_OF_RANGE: 11, 16 | UNIMPLEMENTED: 12, 17 | INTERNAL: 13, 18 | UNAVAILABLE: 14, 19 | DATA_LOSS: 15, 20 | UNAUTHENTICATED: 16 21 | }; 22 | -------------------------------------------------------------------------------- /lib/stream-decoder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const kNoData = 1; 3 | const kReadingSize = 2; 4 | const kReadingMessage = 3; 5 | 6 | 7 | class StreamDecoder { 8 | constructor () { 9 | this.readState = kNoData; 10 | this.readCompressFlag = Buffer.alloc(1); 11 | this.readPartialSize = Buffer.alloc(4); 12 | this.readSizeRemaining = 4; 13 | this.readMessageSize = 0; 14 | this.readMessageRemaining = 0; 15 | this.readPartialMessage = []; 16 | } 17 | 18 | write (data) { 19 | const result = []; 20 | let readHead = 0; 21 | let toRead; 22 | 23 | while (readHead < data.length) { 24 | switch (this.readState) { 25 | case kNoData : 26 | this.readCompressFlag = data.slice(readHead, readHead + 1); 27 | readHead += 1; 28 | this.readState = kReadingSize; 29 | this.readPartialSize.fill(0); 30 | this.readSizeRemaining = 4; 31 | this.readMessageSize = 0; 32 | this.readMessageRemaining = 0; 33 | this.readPartialMessage = []; 34 | break; 35 | case kReadingSize : 36 | toRead = Math.min(data.length - readHead, this.readSizeRemaining); 37 | data.copy( 38 | this.readPartialSize, 4 - this.readSizeRemaining, readHead, 39 | readHead + toRead); 40 | this.readSizeRemaining -= toRead; 41 | readHead += toRead; 42 | // readSizeRemaining >=0 here 43 | if (this.readSizeRemaining === 0) { 44 | this.readMessageSize = this.readPartialSize.readUInt32BE(0); 45 | this.readMessageRemaining = this.readMessageSize; 46 | if (this.readMessageRemaining > 0) { 47 | this.readState = kReadingMessage; 48 | } else { 49 | const message = Buffer.concat( 50 | [this.readCompressFlag, this.readPartialSize], 5); 51 | 52 | this.readState = kNoData; 53 | result.push(message); 54 | } 55 | } 56 | break; 57 | case kReadingMessage : 58 | toRead = 59 | Math.min(data.length - readHead, this.readMessageRemaining); 60 | this.readPartialMessage.push( 61 | data.slice(readHead, readHead + toRead)); 62 | this.readMessageRemaining -= toRead; 63 | readHead += toRead; 64 | // readMessageRemaining >=0 here 65 | if (this.readMessageRemaining === 0) { 66 | // At this point, we have read a full message 67 | const framedMessageBuffers = [ 68 | this.readCompressFlag, this.readPartialSize 69 | ].concat(this.readPartialMessage); 70 | const framedMessage = Buffer.concat( 71 | framedMessageBuffers, this.readMessageSize + 5); 72 | 73 | this.readState = kNoData; 74 | result.push(framedMessage); 75 | } 76 | break; 77 | default : 78 | throw new Error('Unexpected read state'); 79 | } 80 | } 81 | 82 | return result; 83 | } 84 | } 85 | 86 | module.exports = { StreamDecoder }; 87 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Status = require('./status'); 3 | 4 | 5 | function hasGrpcStatusCode (obj) { 6 | return 'code' in obj && 7 | Number.isInteger(obj.code) && 8 | obj.code >= Status.OK && 9 | obj.code <= Status.UNAUTHENTICATED; 10 | } 11 | 12 | 13 | module.exports = { hasGrpcStatusCode }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grpc-server-js", 3 | "version": "0.5.0", 4 | "description": "Pure JavaScript gRPC Server", 5 | "author": "Colin J. Ihrig (http://www.cjihrig.com/)", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "homepage": "https://github.com/cjihrig/grpc-server-js", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/cjihrig/grpc-server-js.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/cjihrig/grpc-server-js/issues" 15 | }, 16 | "license": "MIT", 17 | "scripts": { 18 | "lint": "belly-button -f", 19 | "pretest": "npm run lint", 20 | "test": "lab -v -t 94" 21 | }, 22 | "engines": { 23 | "node": ">=10.10.0" 24 | }, 25 | "devDependencies": { 26 | "@grpc/grpc-js": "1.x.x", 27 | "@grpc/proto-loader": "0.5.x", 28 | "belly-button": "7.x.x", 29 | "cb-barrier": "1.x.x", 30 | "@hapi/lab": "24.x.x" 31 | }, 32 | "keywords": [ 33 | "grpc" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Grpc = require('@grpc/grpc-js'); 3 | const Loader = require('@grpc/proto-loader'); 4 | const protoLoaderOptions = { 5 | keepCase: true, 6 | longs: String, 7 | enums: String, 8 | defaults: true, 9 | oneofs: true 10 | }; 11 | 12 | 13 | function loadProtoFile (file) { 14 | const packageDefinition = Loader.loadSync(file, protoLoaderOptions); 15 | const pkg = Grpc.loadPackageDefinition(packageDefinition); 16 | 17 | return pkg; 18 | } 19 | 20 | 21 | module.exports = { loadProtoFile }; 22 | -------------------------------------------------------------------------------- /test/compression.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Lab = require('@hapi/lab'); 4 | const Compression = require('../lib/compression-filter'); 5 | 6 | // Test shortcuts 7 | const lab = exports.lab = Lab.script(); 8 | const { describe, it } = lab; 9 | 10 | 11 | describe('Compression', () => { 12 | describe('IdentityHandler', () => { 13 | it('constructs an IdentityHandler instance', () => { 14 | const handler = new Compression.IdentityHandler(); 15 | 16 | Assert(handler instanceof Compression.IdentityHandler); 17 | Assert.strictEqual(handler.name, 'identity'); 18 | }); 19 | 20 | it('throws when trying to compress', () => { 21 | const handler = new Compression.IdentityHandler(); 22 | 23 | Assert.throws(() => { 24 | handler.compressMessage(); 25 | }, /Error: Identity encoding does not support compression/); 26 | }); 27 | 28 | it('throws when trying to decompress', () => { 29 | const handler = new Compression.IdentityHandler(); 30 | 31 | Assert.throws(() => { 32 | handler.decompressMessage(); 33 | }, /Error: Identity encoding does not support compression/); 34 | }); 35 | 36 | it('frames and unframes a message', async () => { 37 | const handler = new Compression.IdentityHandler(); 38 | const data = Buffer.from('abc'); 39 | const processed = handler.writeMessage(data); 40 | 41 | Assert(Buffer.isBuffer(processed)); 42 | Assert.strictEqual(processed.byteLength, 8); 43 | Assert.deepStrictEqual(await handler.readMessage(processed), data); 44 | }); 45 | 46 | it('throws during reading if the message is compressed', async () => { 47 | const handler = new Compression.IdentityHandler(); 48 | const data = Buffer.from('abc'); 49 | const processed = handler.writeMessage(data); 50 | 51 | processed.writeUInt8(1, 0); 52 | await Assert.rejects(async () => { 53 | await handler.readMessage(processed); 54 | }, /Error: Identity encoding does not support compression/); 55 | }); 56 | }); 57 | 58 | describe('GzipHandler', () => { 59 | it('constructs a GzipHandler instance', () => { 60 | const handler = new Compression.GzipHandler(); 61 | 62 | Assert(handler instanceof Compression.GzipHandler); 63 | Assert.strictEqual(handler.name, 'gzip'); 64 | }); 65 | 66 | it('frames and unframes a message', async () => { 67 | const handler = new Compression.GzipHandler(); 68 | const data = Buffer.from('abc'); 69 | const processed = await handler.writeMessage(data, true); 70 | 71 | Assert(Buffer.isBuffer(processed)); 72 | Assert(processed.byteLength > 8); 73 | Assert.deepStrictEqual(await handler.readMessage(processed), data); 74 | }); 75 | 76 | it('frames and unframes a message without compressing', async () => { 77 | const handler = new Compression.GzipHandler(); 78 | const data = Buffer.from('abc'); 79 | const processed = await handler.writeMessage(data, false); 80 | 81 | Assert(Buffer.isBuffer(processed)); 82 | Assert.strictEqual(processed.byteLength, 8); 83 | Assert.deepStrictEqual(await handler.readMessage(processed), data); 84 | }); 85 | }); 86 | 87 | describe('DeflateHandler', () => { 88 | it('constructs a DeflateHandler instance', () => { 89 | const handler = new Compression.DeflateHandler(); 90 | 91 | Assert(handler instanceof Compression.DeflateHandler); 92 | Assert.strictEqual(handler.name, 'deflate'); 93 | }); 94 | 95 | it('frames and unframes a message', async () => { 96 | const handler = new Compression.DeflateHandler(); 97 | const data = Buffer.from('abc'); 98 | const processed = await handler.writeMessage(data, true); 99 | 100 | Assert(Buffer.isBuffer(processed)); 101 | Assert(processed.byteLength > 8); 102 | Assert.deepStrictEqual(await handler.readMessage(processed), data); 103 | }); 104 | 105 | it('frames and unframes a message without compressing', async () => { 106 | const handler = new Compression.DeflateHandler(); 107 | const data = Buffer.from('abc'); 108 | const processed = await handler.writeMessage(data, false); 109 | 110 | Assert(Buffer.isBuffer(processed)); 111 | Assert.strictEqual(processed.byteLength, 8); 112 | Assert.deepStrictEqual(await handler.readMessage(processed), data); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/deadline.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Path = require('path'); 4 | const Barrier = require('cb-barrier'); 5 | const Lab = require('@hapi/lab'); 6 | const Grpc = require('@grpc/grpc-js'); 7 | const { Server, ServerCredentials } = require('../lib'); 8 | const { loadProtoFile } = require('./common'); 9 | 10 | // Test shortcuts 11 | const lab = exports.lab = Lab.script(); 12 | const { describe, it, before, after } = lab; 13 | 14 | 15 | const clientInsecureCreds = Grpc.credentials.createInsecure(); 16 | const serverInsecureCreds = ServerCredentials.createInsecure(); 17 | 18 | 19 | describe('Deadlines', () => { 20 | let server; 21 | let client; 22 | 23 | before(async () => { 24 | const proto = loadProtoFile(Path.join(__dirname, 'proto', 'test_service.proto')); 25 | const TestServiceClient = proto.TestService; 26 | 27 | server = new Server(); 28 | server.addService(proto.TestService.service, { 29 | unary (call, cb) { 30 | call.on('cancelled', (reason) => { 31 | Assert.strictEqual(reason, 'deadline'); 32 | }); 33 | 34 | setTimeout(() => { 35 | cb(null, {}); 36 | }, 2000); 37 | } 38 | }); 39 | 40 | const port = await server.bind('localhost:0', serverInsecureCreds); 41 | client = new TestServiceClient(`localhost:${port}`, clientInsecureCreds); 42 | server.start(); 43 | }); 44 | 45 | after(() => { 46 | client.close(); 47 | server.forceShutdown(); 48 | }); 49 | 50 | it('works with deadlines', () => { 51 | const barrier = new Barrier(); 52 | const metadata = new Grpc.Metadata(); 53 | const { 54 | path, 55 | requestSerialize: serialize, 56 | responseDeserialize: deserialize 57 | } = client.unary; 58 | 59 | metadata.set('grpc-timeout', '100m'); 60 | client.makeUnaryRequest(path, serialize, deserialize, {}, metadata, {}, (error, response) => { 61 | Assert.strictEqual(error.code, Grpc.status.DEADLINE_EXCEEDED); 62 | Assert.strictEqual(error.details, 'Deadline exceeded'); 63 | barrier.pass(); 64 | }); 65 | 66 | return barrier; 67 | }); 68 | 69 | it('rejects invalid deadline', () => { 70 | const barrier = new Barrier(); 71 | const metadata = new Grpc.Metadata(); 72 | const { 73 | path, 74 | requestSerialize: serialize, 75 | responseDeserialize: deserialize 76 | } = client.unary; 77 | 78 | metadata.set('grpc-timeout', 'Infinity'); 79 | client.makeUnaryRequest(path, serialize, deserialize, {}, metadata, {}, (error, response) => { 80 | Assert.strictEqual(error.code, Grpc.status.OUT_OF_RANGE); 81 | Assert.strictEqual(error.details, 'Invalid deadline'); 82 | barrier.pass(); 83 | }); 84 | 85 | return barrier; 86 | }); 87 | }); 88 | 89 | 90 | describe('Cancellation', () => { 91 | let server; 92 | let client; 93 | let inHandler = false; 94 | let cancelledInServer = false; 95 | 96 | before(async () => { 97 | const proto = loadProtoFile(Path.join(__dirname, 'proto', 'test_service.proto')); 98 | const TestServiceClient = proto.TestService; 99 | 100 | server = new Server(); 101 | server.addService(proto.TestService.service, { 102 | serverStream (stream) { 103 | inHandler = true; 104 | stream.on('cancelled', (reason) => { 105 | Assert.strictEqual(reason, 'cancelled'); 106 | stream.write({}); 107 | stream.end(); 108 | cancelledInServer = true; 109 | }); 110 | } 111 | }); 112 | 113 | const port = await server.bind('localhost:0', serverInsecureCreds); 114 | client = new TestServiceClient(`localhost:${port}`, clientInsecureCreds); 115 | server.start(); 116 | }); 117 | 118 | after(() => { 119 | client.close(); 120 | server.forceShutdown(); 121 | }); 122 | 123 | it('handles requests cancelled by the client', () => { 124 | const barrier = new Barrier(); 125 | const call = client.serverStream({}); 126 | 127 | call.on('data', Assert.ifError); 128 | call.on('error', (error) => { 129 | Assert.strictEqual(error.code, Grpc.status.CANCELLED); 130 | Assert.strictEqual(error.details, 'Cancelled on client'); 131 | waitForServerCancel(); 132 | }); 133 | 134 | function waitForHandler () { 135 | if (inHandler === true) { 136 | call.cancel(); 137 | return; 138 | } 139 | 140 | setImmediate(waitForHandler); 141 | } 142 | 143 | function waitForServerCancel () { 144 | if (cancelledInServer === true) { 145 | barrier.pass(); 146 | return; 147 | } 148 | 149 | setImmediate(waitForServerCancel); 150 | } 151 | 152 | waitForHandler(); 153 | return barrier; 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /test/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Net = require('net'); 4 | const Path = require('path'); 5 | const Barrier = require('cb-barrier'); 6 | const Lab = require('@hapi/lab'); 7 | const Grpc = require('@grpc/grpc-js'); 8 | const { Server, ServerCredentials } = require('../lib'); 9 | const { loadProtoFile } = require('./common'); 10 | 11 | // Test shortcuts 12 | const lab = exports.lab = Lab.script(); 13 | const { describe, it, before, after } = lab; 14 | 15 | 16 | const protoFile = Path.join(__dirname, 'proto', 'test_service.proto'); 17 | const testServiceDef = loadProtoFile(protoFile); 18 | const TestServiceClient = testServiceDef.TestService; 19 | const clientInsecureCreds = Grpc.credentials.createInsecure(); 20 | const serverInsecureCreds = ServerCredentials.createInsecure(); 21 | 22 | 23 | describe('Client malformed response handling', () => { 24 | let server; 25 | let client; 26 | const badArg = Buffer.from([0xFF]); 27 | 28 | before(async () => { 29 | const malformedTestService = { 30 | unary: { 31 | path: '/TestService/Unary', 32 | requestStream: false, 33 | responseStream: false, 34 | requestDeserialize: identity, 35 | responseSerialize: identity 36 | }, 37 | clientStream: { 38 | path: '/TestService/ClientStream', 39 | requestStream: true, 40 | responseStream: false, 41 | requestDeserialize: identity, 42 | responseSerialize: identity 43 | }, 44 | serverStream: { 45 | path: '/TestService/ServerStream', 46 | requestStream: false, 47 | responseStream: true, 48 | requestDeserialize: identity, 49 | responseSerialize: identity 50 | }, 51 | bidiStream: { 52 | path: '/TestService/BidiStream', 53 | requestStream: true, 54 | responseStream: true, 55 | requestDeserialize: identity, 56 | responseSerialize: identity 57 | } 58 | }; 59 | 60 | server = new Server(); 61 | 62 | server.addService(malformedTestService, { 63 | unary (call, cb) { 64 | cb(null, badArg); 65 | }, 66 | 67 | clientStream (stream, cb) { 68 | stream.on('data', noop); 69 | stream.on('end', () => { 70 | cb(null, badArg); 71 | }); 72 | }, 73 | 74 | serverStream (stream) { 75 | stream.write(badArg); 76 | stream.end(); 77 | }, 78 | 79 | bidiStream (stream) { 80 | stream.on('data', () => { 81 | // Ignore requests 82 | stream.write(badArg); 83 | }); 84 | 85 | stream.on('end', () => { 86 | stream.end(); 87 | }); 88 | } 89 | }); 90 | 91 | const port = await server.bind('localhost:0', serverInsecureCreds); 92 | client = new TestServiceClient(`localhost:${port}`, clientInsecureCreds); 93 | server.start(); 94 | }); 95 | 96 | after(() => { 97 | client.close(); 98 | server.forceShutdown(); 99 | }); 100 | 101 | it('should get an INTERNAL status with a unary call', () => { 102 | const barrier = new Barrier(); 103 | 104 | client.unary({}, (err, data) => { 105 | Assert(err); 106 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 107 | barrier.pass(); 108 | }); 109 | 110 | return barrier; 111 | }); 112 | 113 | it('should get an INTERNAL status with a client stream call', () => { 114 | const barrier = new Barrier(); 115 | const call = client.clientStream((err, data) => { 116 | Assert(err); 117 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 118 | barrier.pass(); 119 | }); 120 | 121 | call.write({}); 122 | call.end(); 123 | 124 | return barrier; 125 | }); 126 | 127 | it('should get an INTERNAL status with a server stream call', () => { 128 | const barrier = new Barrier(); 129 | const call = client.serverStream({}); 130 | 131 | call.on('data', noop); 132 | call.on('error', (err) => { 133 | Assert(err); 134 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 135 | barrier.pass(); 136 | }); 137 | 138 | return barrier; 139 | }); 140 | 141 | it('should get an INTERNAL status with a bidi stream call', () => { 142 | const barrier = new Barrier(); 143 | const call = client.bidiStream(); 144 | 145 | call.on('data', noop); 146 | call.on('error', (err) => { 147 | Assert(err); 148 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 149 | barrier.pass(); 150 | }); 151 | 152 | call.write({}); 153 | call.end(); 154 | 155 | return barrier; 156 | }); 157 | }); 158 | 159 | describe('Server serialization failure handling', () => { 160 | let client; 161 | let server; 162 | 163 | before(async () => { 164 | function serializeFail (obj) { 165 | throw new Error('Serialization failed'); 166 | } 167 | 168 | const malformedTestService = { 169 | unary: { 170 | path: '/TestService/Unary', 171 | requestStream: false, 172 | responseStream: false, 173 | requestDeserialize: identity, 174 | responseSerialize: serializeFail 175 | }, 176 | clientStream: { 177 | path: '/TestService/ClientStream', 178 | requestStream: true, 179 | responseStream: false, 180 | requestDeserialize: identity, 181 | responseSerialize: serializeFail 182 | }, 183 | serverStream: { 184 | path: '/TestService/ServerStream', 185 | requestStream: false, 186 | responseStream: true, 187 | requestDeserialize: identity, 188 | responseSerialize: serializeFail 189 | }, 190 | bidiStream: { 191 | path: '/TestService/BidiStream', 192 | requestStream: true, 193 | responseStream: true, 194 | requestDeserialize: identity, 195 | responseSerialize: serializeFail 196 | } 197 | }; 198 | 199 | server = new Server(); 200 | server.addService(malformedTestService, { 201 | unary (call, cb) { 202 | cb(null, {}); 203 | }, 204 | 205 | clientStream (stream, cb) { 206 | stream.on('data', noop); 207 | stream.on('end', () => { 208 | cb(null, {}); 209 | }); 210 | }, 211 | 212 | serverStream (stream) { 213 | stream.write({}); 214 | stream.end(); 215 | }, 216 | 217 | bidiStream (stream) { 218 | stream.on('data', () => { 219 | // Ignore requests 220 | stream.write({}); 221 | }); 222 | stream.on('end', () => { 223 | stream.end(); 224 | }); 225 | } 226 | }); 227 | 228 | const port = await server.bind('localhost:0', serverInsecureCreds); 229 | 230 | client = new TestServiceClient(`localhost:${port}`, clientInsecureCreds); 231 | server.start(); 232 | }); 233 | 234 | after(() => { 235 | client.close(); 236 | server.forceShutdown(); 237 | }); 238 | 239 | it('should get an INTERNAL status with a unary call', () => { 240 | const barrier = new Barrier(); 241 | 242 | client.unary({}, (err, data) => { 243 | Assert(err); 244 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 245 | barrier.pass(); 246 | }); 247 | 248 | return barrier; 249 | }); 250 | 251 | it('should get an INTERNAL status with a client stream call', () => { 252 | const barrier = new Barrier(); 253 | const call = client.clientStream((err, data) => { 254 | Assert(err); 255 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 256 | barrier.pass(); 257 | }); 258 | 259 | call.write({}); 260 | call.end(); 261 | 262 | return barrier; 263 | }); 264 | 265 | it('should get an INTERNAL status with a server stream call', () => { 266 | const barrier = new Barrier(); 267 | const call = client.serverStream({}); 268 | 269 | call.on('data', noop); 270 | call.on('error', (err) => { 271 | Assert(err); 272 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 273 | barrier.pass(); 274 | }); 275 | 276 | return barrier; 277 | }); 278 | 279 | it('should get an INTERNAL status with a bidi stream call', () => { 280 | const barrier = new Barrier(); 281 | const call = client.bidiStream(); 282 | 283 | call.on('data', noop); 284 | call.on('error', (err) => { 285 | Assert(err); 286 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 287 | barrier.pass(); 288 | }); 289 | 290 | call.write({}); 291 | call.end(); 292 | 293 | return barrier; 294 | }); 295 | }); 296 | 297 | describe('Other conditions', () => { 298 | let client; 299 | let server; 300 | let port; 301 | 302 | before(async () => { 303 | const trailerMetadata = new Grpc.Metadata(); 304 | const existingMetadata = new Grpc.Metadata(); 305 | 306 | server = new Server(); 307 | trailerMetadata.add('trailer-present', 'yes'); 308 | existingMetadata.add('existing-present', 'yes'); 309 | 310 | function validatePeer (call) { 311 | const [peerAddress, peerPort] = call.getPeer().split(':'); 312 | 313 | Assert(Net.isIP(peerAddress)); 314 | Assert.strictEqual(String(Number(peerPort)), peerPort); 315 | Assert(Number.isSafeInteger(Number(peerPort))); 316 | } 317 | 318 | server.addService(TestServiceClient.service, { 319 | unary (call, cb) { 320 | const req = call.request; 321 | 322 | validatePeer(call); 323 | 324 | if (req.error) { 325 | const details = req.message || 'Requested error'; 326 | const response = { 327 | code: Grpc.status.UNKNOWN, 328 | details 329 | }; 330 | 331 | if (req.message === 'existing-metadata') { 332 | response.metadata = existingMetadata; 333 | } 334 | 335 | cb(response, null, trailerMetadata); 336 | } else { 337 | cb(null, { count: 1 }, trailerMetadata); 338 | } 339 | }, 340 | 341 | clientStream (stream, cb) { 342 | let count = 0; 343 | let errored; 344 | 345 | validatePeer(stream); 346 | 347 | stream.on('data', (data) => { 348 | if (data.error) { 349 | const message = data.message || 'Requested error'; 350 | errored = true; 351 | cb(new Error(message), null, trailerMetadata); 352 | } else { 353 | count++; 354 | } 355 | }); 356 | 357 | stream.on('end', () => { 358 | if (!errored) { 359 | cb(null, { count }, trailerMetadata); 360 | } 361 | }); 362 | }, 363 | 364 | serverStream (stream) { 365 | const req = stream.request; 366 | 367 | validatePeer(stream); 368 | 369 | if (req.error) { 370 | stream.emit('error', { 371 | code: Grpc.status.UNKNOWN, 372 | details: req.message || 'Requested error', 373 | metadata: trailerMetadata 374 | }); 375 | } else { 376 | for (let i = 0; i < 5; i++) { 377 | stream.write({ count: i }); 378 | } 379 | 380 | stream.end(trailerMetadata); 381 | } 382 | }, 383 | 384 | bidiStream (stream) { 385 | validatePeer(stream); 386 | 387 | let count = 0; 388 | stream.on('data', (data) => { 389 | if (data.error) { 390 | const message = data.message || 'Requested error'; 391 | const err = new Error(message); 392 | 393 | err.metadata = trailerMetadata.clone(); 394 | err.metadata.add('count', '' + count); 395 | stream.emit('error', err); 396 | } else { 397 | stream.write({ count }); 398 | count++; 399 | } 400 | }); 401 | 402 | stream.on('end', () => { 403 | stream.end(trailerMetadata); 404 | }); 405 | } 406 | }); 407 | 408 | port = await server.bind('localhost:0', serverInsecureCreds); 409 | client = new TestServiceClient(`localhost:${port}`, clientInsecureCreds); 410 | server.start(); 411 | }); 412 | 413 | after(function () { 414 | client.close(); 415 | server.forceShutdown(); 416 | }); 417 | 418 | describe('Server receiving bad input', () => { 419 | let misbehavingClient; 420 | const badArg = Buffer.from([0xFF]); 421 | 422 | before(() => { 423 | const testServiceAttrs = { 424 | unary: { 425 | path: '/TestService/Unary', 426 | requestStream: false, 427 | responseStream: false, 428 | requestSerialize: identity, 429 | responseDeserialize: identity 430 | }, 431 | clientStream: { 432 | path: '/TestService/ClientStream', 433 | requestStream: true, 434 | responseStream: false, 435 | requestSerialize: identity, 436 | responseDeserialize: identity 437 | }, 438 | serverStream: { 439 | path: '/TestService/ServerStream', 440 | requestStream: false, 441 | responseStream: true, 442 | requestSerialize: identity, 443 | responseDeserialize: identity 444 | }, 445 | bidiStream: { 446 | path: '/TestService/BidiStream', 447 | requestStream: true, 448 | responseStream: true, 449 | requestSerialize: identity, 450 | responseDeserialize: identity 451 | } 452 | }; 453 | 454 | const Client = Grpc.makeGenericClientConstructor(testServiceAttrs, 'TestService'); 455 | 456 | misbehavingClient = new Client(`localhost:${port}`, clientInsecureCreds); 457 | }); 458 | 459 | after(() => { 460 | misbehavingClient.close(); 461 | }); 462 | 463 | it('should respond correctly to a unary call', () => { 464 | const barrier = new Barrier(); 465 | 466 | misbehavingClient.unary(badArg, (err, data) => { 467 | Assert(err); 468 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 469 | barrier.pass(); 470 | }); 471 | 472 | return barrier; 473 | }); 474 | 475 | it('should respond correctly to a client stream', () => { 476 | const barrier = new Barrier(); 477 | const call = misbehavingClient.clientStream((err, data) => { 478 | Assert(err); 479 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 480 | barrier.pass(); 481 | }); 482 | 483 | call.write(badArg); 484 | call.end(); 485 | 486 | return barrier; 487 | }); 488 | 489 | it('should respond correctly to a server stream', () => { 490 | const barrier = new Barrier(); 491 | const call = misbehavingClient.serverStream(badArg); 492 | 493 | call.on('data', (data) => { 494 | Assert.fail(data); 495 | }); 496 | 497 | call.on('error', (err) => { 498 | Assert(err); 499 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 500 | barrier.pass(); 501 | }); 502 | 503 | return barrier; 504 | }); 505 | 506 | it('should respond correctly to a bidi stream', () => { 507 | const barrier = new Barrier(); 508 | const call = misbehavingClient.bidiStream(); 509 | 510 | call.on('data', (data) => { 511 | Assert.fail(data); 512 | }); 513 | 514 | call.on('error', (err) => { 515 | Assert(err); 516 | Assert.strictEqual(err.code, Grpc.status.INTERNAL); 517 | barrier.pass(); 518 | }); 519 | 520 | call.write(badArg); 521 | call.end(); 522 | return barrier; 523 | }); 524 | }); 525 | 526 | describe('Trailing metadata', () => { 527 | it('should be present when a unary call succeeds', () => { 528 | const barrier = new Barrier(2); 529 | const call = client.unary({ error: false }, (err, data) => { 530 | Assert.ifError(err); 531 | barrier.pass(); 532 | }); 533 | 534 | call.on('status', (status) => { 535 | Assert.deepStrictEqual(status.metadata.get('trailer-present'), ['yes']); 536 | barrier.pass(); 537 | }); 538 | 539 | return barrier; 540 | }); 541 | 542 | it('should be present when a unary call fails', () => { 543 | const barrier = new Barrier(2); 544 | const call = client.unary({ error: true }, (err, data) => { 545 | Assert(err); 546 | barrier.pass(); 547 | }); 548 | 549 | call.on('status', (status) => { 550 | Assert.deepStrictEqual(status.metadata.get('trailer-present'), ['yes']); 551 | barrier.pass(); 552 | }); 553 | 554 | return barrier; 555 | }); 556 | 557 | it('should be present when a client stream call succeeds', () => { 558 | const barrier = new Barrier(2); 559 | const call = client.clientStream((err, data) => { 560 | Assert.ifError(err); 561 | barrier.pass(); 562 | }); 563 | 564 | call.write({ error: false }); 565 | call.write({ error: false }); 566 | call.end(); 567 | 568 | call.on('status', (status) => { 569 | Assert.deepStrictEqual(status.metadata.get('trailer-present'), ['yes']); 570 | barrier.pass(); 571 | }); 572 | 573 | return barrier; 574 | }); 575 | 576 | it('should be present when a client stream call fails', () => { 577 | const barrier = new Barrier(2); 578 | const call = client.clientStream((err, data) => { 579 | Assert(err); 580 | barrier.pass(); 581 | }); 582 | 583 | call.write({ error: false }); 584 | call.write({ error: true }); 585 | call.end(); 586 | 587 | call.on('status', (status) => { 588 | Assert.deepStrictEqual(status.metadata.get('trailer-present'), ['yes']); 589 | barrier.pass(); 590 | }); 591 | 592 | return barrier; 593 | }); 594 | 595 | it('should be present when a server stream call succeeds', () => { 596 | const barrier = new Barrier(); 597 | const call = client.serverStream({ error: false }); 598 | 599 | call.on('data', noop); 600 | call.on('status', (status) => { 601 | Assert.strictEqual(status.code, Grpc.status.OK); 602 | Assert.deepStrictEqual(status.metadata.get('trailer-present'), ['yes']); 603 | barrier.pass(); 604 | }); 605 | 606 | return barrier; 607 | }); 608 | 609 | it('should be present when a server stream call fails', () => { 610 | const barrier = new Barrier(); 611 | const call = client.serverStream({ error: true }); 612 | 613 | call.on('data', noop); 614 | call.on('error', (error) => { 615 | Assert.deepStrictEqual(error.metadata.get('trailer-present'), ['yes']); 616 | barrier.pass(); 617 | }); 618 | 619 | return barrier; 620 | }); 621 | 622 | it('should be present when a bidi stream succeeds', () => { 623 | const barrier = new Barrier(); 624 | const call = client.bidiStream(); 625 | 626 | call.write({ error: false }); 627 | call.write({ error: false }); 628 | call.end(); 629 | call.on('data', noop); 630 | call.on('status', (status) => { 631 | Assert.strictEqual(status.code, Grpc.status.OK); 632 | Assert.deepStrictEqual(status.metadata.get('trailer-present'), ['yes']); 633 | barrier.pass(); 634 | }); 635 | 636 | return barrier; 637 | }); 638 | 639 | it('should be present when a bidi stream fails', () => { 640 | const barrier = new Barrier(); 641 | const call = client.bidiStream(); 642 | 643 | call.write({ error: false }); 644 | call.write({ error: true }); 645 | call.end(); 646 | call.on('data', noop); 647 | call.on('error', (error) => { 648 | Assert.deepStrictEqual(error.metadata.get('trailer-present'), ['yes']); 649 | barrier.pass(); 650 | }); 651 | 652 | return barrier; 653 | }); 654 | }); 655 | 656 | it('existing metadata is not overwritten when a unary call fails', () => { 657 | const barrier = new Barrier(2); 658 | const call = client.unary({ 659 | error: true, 660 | message: 'existing-metadata' 661 | }, (err, data) => { 662 | Assert(err); 663 | barrier.pass(); 664 | }); 665 | 666 | call.on('status', (status) => { 667 | Assert.deepStrictEqual(status.metadata.get('existing-present'), ['yes']); 668 | barrier.pass(); 669 | }); 670 | 671 | return barrier; 672 | }); 673 | 674 | describe('Error object should contain the status', () => { 675 | it('for a unary call', () => { 676 | const barrier = new Barrier(); 677 | 678 | client.unary({ error: true }, (err, data) => { 679 | Assert(err); 680 | Assert.strictEqual(err.code, Grpc.status.UNKNOWN); 681 | Assert.strictEqual(err.details, 'Requested error'); 682 | barrier.pass(); 683 | }); 684 | 685 | return barrier; 686 | }); 687 | 688 | it('for a client stream call', () => { 689 | const barrier = new Barrier(); 690 | const call = client.clientStream((err, data) => { 691 | Assert(err); 692 | Assert.strictEqual(err.code, Grpc.status.UNKNOWN); 693 | Assert.strictEqual(err.details, 'Requested error'); 694 | barrier.pass(); 695 | }); 696 | 697 | call.write({ error: false }); 698 | call.write({ error: true }); 699 | call.end(); 700 | 701 | return barrier; 702 | }); 703 | 704 | it('for a server stream call', () => { 705 | const barrier = new Barrier(); 706 | const call = client.serverStream({ error: true }); 707 | 708 | call.on('data', noop); 709 | call.on('error', (error) => { 710 | Assert.strictEqual(error.code, Grpc.status.UNKNOWN); 711 | Assert.strictEqual(error.details, 'Requested error'); 712 | barrier.pass(); 713 | }); 714 | 715 | return barrier; 716 | }); 717 | 718 | it('for a bidi stream call', () => { 719 | const barrier = new Barrier(); 720 | const call = client.bidiStream(); 721 | 722 | call.write({ error: false }); 723 | call.write({ error: true }); 724 | call.end(); 725 | call.on('data', noop); 726 | call.on('error', (error) => { 727 | Assert.strictEqual(error.code, Grpc.status.UNKNOWN); 728 | Assert.strictEqual(error.details, 'Requested error'); 729 | barrier.pass(); 730 | }); 731 | 732 | return barrier; 733 | }); 734 | 735 | it('for a UTF-8 error message', () => { 736 | const barrier = new Barrier(); 737 | 738 | client.unary({ error: true, message: '測試字符串' }, (err, data) => { 739 | Assert(err); 740 | Assert.strictEqual(err.code, Grpc.status.UNKNOWN); 741 | Assert.strictEqual(err.details, '測試字符串'); 742 | barrier.pass(); 743 | }); 744 | 745 | return barrier; 746 | }); 747 | 748 | it('for an error message containing a comma', () => { 749 | const barrier = new Barrier(); 750 | 751 | client.unary({ error: true, message: 'foo, bar, and baz' }, (err, data) => { 752 | Assert(err); 753 | Assert.strictEqual(err.code, Grpc.status.UNKNOWN); 754 | Assert.strictEqual(err.details, 'foo, bar, and baz'); 755 | barrier.pass(); 756 | }); 757 | 758 | return barrier; 759 | }); 760 | }); 761 | }); 762 | 763 | 764 | function identity (arg) { 765 | return arg; 766 | } 767 | 768 | 769 | function noop () {} 770 | -------------------------------------------------------------------------------- /test/fixtures/README: -------------------------------------------------------------------------------- 1 | CONFIRMEDTESTKEY 2 | -------------------------------------------------------------------------------- /test/fixtures/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICSjCCAbOgAwIBAgIJAJHGGR4dGioHMA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQxDzANBgNVBAMTBnRlc3RjYTAeFw0xNDExMTEyMjMxMjla 5 | Fw0yNDExMDgyMjMxMjlaMFYxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0 6 | YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMT 7 | BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwEDfBV5MYdlHVHJ7 8 | +L4nxrZy7mBfAVXpOc5vMYztssUI7mL2/iYujiIXM+weZYNTEpLdjyJdu7R5gGUu 9 | g1jSVK/EPHfc74O7AyZU34PNIP4Sh33N+/A5YexrNgJlPY+E3GdVYi4ldWJjgkAd 10 | Qah2PH5ACLrIIC6tRka9hcaBlIECAwEAAaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNV 11 | HQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADgYEAHzC7jdYlzAVmddi/gdAeKPau 12 | sPBG/C2HCWqHzpCUHcKuvMzDVkY/MP2o6JIW2DBbY64bO/FceExhjcykgaYtCH/m 13 | oIU63+CFOTtR7otyQAWHqXa7q4SbCDlG7DyRFxqG0txPtGvy12lgldA2+RgcigQG 14 | Dfcog5wrJytaQ6UA0wE= 15 | -----END CERTIFICATE----- 16 | -------------------------------------------------------------------------------- /test/fixtures/server1.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOHDFScoLCVJpYDD 3 | M4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1BgzkWF+slf 4 | 3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd9N8YwbBY 5 | AckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAECgYAn7qGnM2vbjJNBm0VZCkOkTIWm 6 | V10okw7EPJrdL2mkre9NasghNXbE1y5zDshx5Nt3KsazKOxTT8d0Jwh/3KbaN+YY 7 | tTCbKGW0pXDRBhwUHRcuRzScjli8Rih5UOCiZkhefUTcRb6xIhZJuQy71tjaSy0p 8 | dHZRmYyBYO2YEQ8xoQJBAPrJPhMBkzmEYFtyIEqAxQ/o/A6E+E4w8i+KM7nQCK7q 9 | K4JXzyXVAjLfyBZWHGM2uro/fjqPggGD6QH1qXCkI4MCQQDmdKeb2TrKRh5BY1LR 10 | 81aJGKcJ2XbcDu6wMZK4oqWbTX2KiYn9GB0woM6nSr/Y6iy1u145YzYxEV/iMwff 11 | DJULAkB8B2MnyzOg0pNFJqBJuH29bKCcHa8gHJzqXhNO5lAlEbMK95p/P2Wi+4Hd 12 | aiEIAF1BF326QJcvYKmwSmrORp85AkAlSNxRJ50OWrfMZnBgzVjDx3xG6KsFQVk2 13 | ol6VhqL6dFgKUORFUWBvnKSyhjJxurlPEahV6oo6+A+mPhFY8eUvAkAZQyTdupP3 14 | XEFQKctGz+9+gKkemDp7LBBMEMBXrGTLPhpEfcjv/7KPdnFHYmhYeBTBnuVmTVWe 15 | F98XJ7tIFfJq 16 | -----END PRIVATE KEY----- 17 | -------------------------------------------------------------------------------- /test/fixtures/server1.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICnDCCAgWgAwIBAgIBBzANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJBVTET 3 | MBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQ 4 | dHkgTHRkMQ8wDQYDVQQDEwZ0ZXN0Y2EwHhcNMTUxMTA0MDIyMDI0WhcNMjUxMTAx 5 | MDIyMDI0WjBlMQswCQYDVQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNV 6 | BAcTB0NoaWNhZ28xFTATBgNVBAoTDEV4YW1wbGUsIENvLjEaMBgGA1UEAxQRKi50 7 | ZXN0Lmdvb2dsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOHDFSco 8 | LCVJpYDDM4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1Bg 9 | zkWF+slf3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd 10 | 9N8YwbBYAckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAGjazBpMAkGA1UdEwQCMAAw 11 | CwYDVR0PBAQDAgXgME8GA1UdEQRIMEaCECoudGVzdC5nb29nbGUuZnKCGHdhdGVy 12 | em9vaS50ZXN0Lmdvb2dsZS5iZYISKi50ZXN0LnlvdXR1YmUuY29thwTAqAEDMA0G 13 | CSqGSIb3DQEBCwUAA4GBAJFXVifQNub1LUP4JlnX5lXNlo8FxZ2a12AFQs+bzoJ6 14 | hM044EDjqyxUqSbVePK0ni3w1fHQB5rY9yYC5f8G7aqqTY1QOhoUk8ZTSTRpnkTh 15 | y4jjdvTZeLDVBlueZUTDRmy2feY5aZIU18vFDK08dTG0A87pppuv1LNIR3loveU8 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /test/logging.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Lab = require('@hapi/lab'); 4 | const Grpc = require('../lib'); 5 | const Logging = require('../lib/logging'); 6 | 7 | // Test shortcuts 8 | const lab = exports.lab = Lab.script(); 9 | const { describe, it, afterEach } = lab; 10 | 11 | 12 | describe('Logging', () => { 13 | afterEach(() => { 14 | // Ensure that the logger is restored to its defaults after each test. 15 | Grpc.setLogger(console); 16 | Grpc.setLogVerbosity(Grpc.logVerbosity.ERROR); 17 | }); 18 | 19 | it('logger defaults to console', () => { 20 | Assert.strictEqual(Logging.getLogger(), console); 21 | }); 22 | 23 | it('sets the logger to a new value', () => { 24 | const logger = {}; 25 | 26 | Grpc.setLogger(logger); 27 | Assert.strictEqual(Logging.getLogger(), logger); 28 | }); 29 | 30 | it('gates logging based on severity', () => { 31 | const output = []; 32 | const logger = { 33 | error (...args) { 34 | output.push(args); 35 | } 36 | }; 37 | 38 | Grpc.setLogger(logger); 39 | 40 | // The default verbosity (ERROR) should not log DEBUG or INFO data. 41 | Logging.log(Grpc.logVerbosity.DEBUG, 4, 5, 6); 42 | Logging.log(Grpc.logVerbosity.INFO, 7, 8); 43 | Logging.log(Grpc.logVerbosity.ERROR, 'j', 'k'); 44 | 45 | // The DEBUG verbosity should log everything. 46 | Grpc.setLogVerbosity(Grpc.logVerbosity.DEBUG); 47 | Logging.log(Grpc.logVerbosity.DEBUG, 'a', 'b', 'c'); 48 | Logging.log(Grpc.logVerbosity.INFO, 'd', 'e'); 49 | Logging.log(Grpc.logVerbosity.ERROR, 'f'); 50 | 51 | // The INFO verbosity should not log DEBUG data. 52 | Grpc.setLogVerbosity(Grpc.logVerbosity.INFO); 53 | Logging.log(Grpc.logVerbosity.DEBUG, 1, 2, 3); 54 | Logging.log(Grpc.logVerbosity.INFO, 'g'); 55 | Logging.log(Grpc.logVerbosity.ERROR, 'h', 'i'); 56 | 57 | Assert.deepStrictEqual(output, [ 58 | ['j', 'k'], 59 | ['a', 'b', 'c'], 60 | ['d', 'e'], 61 | ['f'], 62 | ['g'], 63 | ['h', 'i'] 64 | ]); 65 | }); 66 | 67 | it('handles loggers with no error() function', () => { 68 | const logger = {}; 69 | 70 | Grpc.setLogger(logger); 71 | Logging.log(Grpc.logVerbosity.ERROR, 'foo'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/metadata.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Lab = require('@hapi/lab'); 4 | const { Metadata } = require('../lib'); 5 | 6 | // Test shortcuts 7 | const lab = exports.lab = Lab.script(); 8 | const { describe, it, beforeEach } = lab; 9 | 10 | 11 | describe('Metadata', () => { 12 | const validKeyChars = '0123456789abcdefghijklmnopqrstuvwxyz_-.'; 13 | const validNonBinValueChars = range(0x20, 0x7f) 14 | .map((code) => { return String.fromCharCode(code); }) 15 | .join(''); 16 | let metadata; 17 | 18 | beforeEach(() => { 19 | metadata = new Metadata(); 20 | }); 21 | 22 | describe('set', () => { 23 | it('Only accepts string values for non-binary keys', () => { 24 | Assert.throws(() => { 25 | metadata.set('key', Buffer.from('value')); 26 | }); 27 | 28 | Assert.doesNotThrow(() => { 29 | metadata.set('key', 'value'); 30 | }); 31 | }); 32 | 33 | it('Only accepts Buffer values for binary keys', () => { 34 | Assert.throws(() => { 35 | metadata.set('key-bin', 'value'); 36 | }); 37 | 38 | Assert.doesNotThrow(() => { 39 | metadata.set('key-bin', Buffer.from('value')); 40 | }); 41 | }); 42 | 43 | it('Rejects invalid keys', () => { 44 | Assert.doesNotThrow(() => { 45 | metadata.set(validKeyChars, 'value'); 46 | }); 47 | 48 | Assert.throws(() => { 49 | metadata.set('key$', 'value'); 50 | }, /Error: Metadata key "key\$" contains illegal characters/); 51 | 52 | Assert.throws(() => { 53 | metadata.set('', 'value'); 54 | }); 55 | }); 56 | 57 | it('Rejects values with non-ASCII characters', () => { 58 | Assert.doesNotThrow(() => { 59 | metadata.set('key', validNonBinValueChars); 60 | }); 61 | Assert.throws(() => { 62 | metadata.set('key', 'résumé'); 63 | }); 64 | }); 65 | 66 | it('Saves values that can be retrieved', () => { 67 | metadata.set('key', 'value'); 68 | Assert.deepStrictEqual(metadata.get('key'), ['value']); 69 | }); 70 | 71 | it('Overwrites previous values', () => { 72 | metadata.set('key', 'value1'); 73 | metadata.set('key', 'value2'); 74 | Assert.deepStrictEqual(metadata.get('key'), ['value2']); 75 | }); 76 | 77 | it('Normalizes keys', () => { 78 | metadata.set('Key', 'value1'); 79 | Assert.deepStrictEqual(metadata.get('key'), ['value1']); 80 | metadata.set('KEY', 'value2'); 81 | Assert.deepStrictEqual(metadata.get('key'), ['value2']); 82 | }); 83 | }); 84 | 85 | describe('add', () => { 86 | it('Only accepts string values for non-binary keys', () => { 87 | Assert.throws(() => { 88 | metadata.add('key', Buffer.from('value')); 89 | }); 90 | 91 | Assert.doesNotThrow(() => { 92 | metadata.add('key', 'value'); 93 | }); 94 | }); 95 | 96 | it('Only accepts Buffer values for binary keys', () => { 97 | Assert.throws(() => { 98 | metadata.add('key-bin', 'value'); 99 | }); 100 | 101 | Assert.doesNotThrow(() => { 102 | metadata.add('key-bin', Buffer.from('value')); 103 | }); 104 | }); 105 | 106 | it('Rejects invalid keys', () => { 107 | Assert.throws(() => { 108 | metadata.add('key$', 'value'); 109 | }); 110 | 111 | Assert.throws(() => { 112 | metadata.add('', 'value'); 113 | }); 114 | }); 115 | 116 | it('Saves values that can be retrieved', () => { 117 | metadata.add('key', 'value'); 118 | Assert.deepStrictEqual(metadata.get('key'), ['value']); 119 | }); 120 | 121 | it('Combines with previous values', () => { 122 | metadata.add('key', 'value1'); 123 | metadata.add('key', 'value2'); 124 | Assert.deepStrictEqual(metadata.get('key'), ['value1', 'value2']); 125 | }); 126 | 127 | it('Normalizes keys', () => { 128 | metadata.add('Key', 'value1'); 129 | Assert.deepStrictEqual(metadata.get('key'), ['value1']); 130 | metadata.add('KEY', 'value2'); 131 | Assert.deepStrictEqual(metadata.get('key'), ['value1', 'value2']); 132 | }); 133 | }); 134 | 135 | describe('remove', () => { 136 | it('clears values from a key', () => { 137 | metadata.add('key', 'value'); 138 | metadata.remove('key'); 139 | Assert.deepStrictEqual(metadata.get('key'), []); 140 | }); 141 | 142 | it('Normalizes keys', () => { 143 | metadata.add('key', 'value'); 144 | metadata.remove('KEY'); 145 | Assert.deepStrictEqual(metadata.get('key'), []); 146 | }); 147 | }); 148 | 149 | describe('get', () => { 150 | beforeEach(() => { 151 | metadata.add('key', 'value1'); 152 | metadata.add('key', 'value2'); 153 | metadata.add('key-bin', Buffer.from('value')); 154 | }); 155 | 156 | it('gets all values associated with a key', () => { 157 | Assert.deepStrictEqual(metadata.get('key'), ['value1', 'value2']); 158 | }); 159 | 160 | it('Normalizes keys', () => { 161 | Assert.deepStrictEqual(metadata.get('KEY'), ['value1', 'value2']); 162 | }); 163 | 164 | it('returns an empty list for non-existent keys', () => { 165 | Assert.deepStrictEqual(metadata.get('non-existent-key'), []); 166 | }); 167 | 168 | it('returns Buffers for binary keys', () => { 169 | Assert.ok(metadata.get('key-bin')[0] instanceof Buffer); 170 | }); 171 | }); 172 | 173 | describe('getMap', () => { 174 | it('gets a map of keys to values', () => { 175 | metadata.add('key1', 'value1'); 176 | metadata.add('Key2', 'value2'); 177 | metadata.add('KEY3', 'value3a'); 178 | metadata.add('KEY3', 'value3b'); 179 | metadata.add('key4-bin', Buffer.from('value4')); 180 | Assert.deepStrictEqual(metadata.getMap(), { 181 | key1: 'value1', 182 | key2: 'value2', 183 | key3: 'value3a', 184 | 'key4-bin': Buffer.from('value4') 185 | }); 186 | }); 187 | }); 188 | 189 | describe('clone', () => { 190 | it('retains values from the original', () => { 191 | metadata.add('key', 'value'); 192 | const copy = metadata.clone(); 193 | Assert.deepStrictEqual(copy.get('key'), ['value']); 194 | }); 195 | 196 | it('Does not see newly added values', () => { 197 | metadata.add('key', 'value1'); 198 | const copy = metadata.clone(); 199 | metadata.add('key', 'value2'); 200 | Assert.deepStrictEqual(copy.get('key'), ['value1']); 201 | }); 202 | 203 | it('Does not add new values to the original', () => { 204 | metadata.add('key', 'value1'); 205 | const copy = metadata.clone(); 206 | copy.add('key', 'value2'); 207 | Assert.deepStrictEqual(metadata.get('key'), ['value1']); 208 | }); 209 | 210 | it('Copy cannot modify binary values in the original', () => { 211 | const buf = Buffer.from('value-bin'); 212 | metadata.add('key-bin', buf); 213 | const copy = metadata.clone(); 214 | const copyBuf = copy.get('key-bin')[0]; 215 | Assert.deepStrictEqual(copyBuf, buf); 216 | copyBuf.fill(0); 217 | Assert.notDeepStrictEqual(copyBuf, buf); 218 | }); 219 | }); 220 | 221 | describe('merge', () => { 222 | it('appends values from a given metadata object', () => { 223 | metadata.add('key1', 'value1'); 224 | metadata.add('Key2', 'value2a'); 225 | metadata.add('KEY3', 'value3a'); 226 | metadata.add('key4', 'value4'); 227 | const metadata2 = new Metadata(); 228 | metadata2.add('KEY1', 'value1'); 229 | metadata2.add('key2', 'value2b'); 230 | metadata2.add('key3', 'value3b'); 231 | metadata2.add('key5', 'value5a'); 232 | metadata2.add('key5', 'value5b'); 233 | const metadata2IR = metadata2.internalRepr; 234 | metadata.merge(metadata2); 235 | // Ensure metadata2 didn't change 236 | Assert.deepStrictEqual( 237 | metadata2.internalRepr, 238 | metadata2IR 239 | ); 240 | Assert.deepStrictEqual(metadata.get('key1'), ['value1', 'value1']); 241 | Assert.deepStrictEqual(metadata.get('key2'), ['value2a', 'value2b']); 242 | Assert.deepStrictEqual(metadata.get('key3'), ['value3a', 'value3b']); 243 | Assert.deepStrictEqual(metadata.get('key4'), ['value4']); 244 | Assert.deepStrictEqual(metadata.get('key5'), ['value5a', 'value5b']); 245 | }); 246 | }); 247 | 248 | describe('toHttp2Headers', () => { 249 | it('creates an OutgoingHttpHeaders object with expected values', () => { 250 | metadata.add('key1', 'value1'); 251 | metadata.add('Key2', 'value2'); 252 | metadata.add('KEY3', 'value3a'); 253 | metadata.add('key3', 'value3b'); 254 | metadata.add('key-bin', Buffer.from(range(0, 16))); 255 | metadata.add('key-bin', Buffer.from(range(16, 32))); 256 | metadata.add('key-bin', Buffer.from(range(0, 32))); 257 | const headers = metadata.toHttp2Headers(); 258 | Assert.deepStrictEqual(headers, { 259 | key1: ['value1'], 260 | key2: ['value2'], 261 | key3: ['value3a', 'value3b'], 262 | 'key-bin': [ 263 | 'AAECAwQFBgcICQoLDA0ODw==', 264 | 'EBESExQVFhcYGRobHB0eHw==', 265 | 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' 266 | ] 267 | }); 268 | }); 269 | 270 | it('creates an empty header object from empty Metadata', () => { 271 | Assert.deepStrictEqual(metadata.toHttp2Headers(), {}); 272 | }); 273 | }); 274 | 275 | describe('fromHttp2Headers', () => { 276 | it('creates a Metadata object with expected values', () => { 277 | const headers = { 278 | key1: 'value1', 279 | key2: ['value2'], 280 | key3: ['value3a', 'value3b'], 281 | key4: ['key4a, key4b'], 282 | key5: 'value5a, value5b', 283 | 'key-bin': [ 284 | 'AAECAwQFBgcICQoLDA0ODw==', 285 | 'EBESExQVFhcYGRobHB0eHw==', 286 | 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' 287 | ] 288 | }; 289 | const metadataFromHeaders = Metadata.fromHttp2Headers(headers); 290 | const internalRepr = metadataFromHeaders.internalRepr; 291 | const expected = new Map([ 292 | ['key1', ['value1']], 293 | ['key2', ['value2']], 294 | ['key3', ['value3a', 'value3b']], 295 | ['key4', ['key4a, key4b']], 296 | ['key5', ['value5a, value5b']], 297 | [ 298 | 'key-bin', 299 | [ 300 | Buffer.from(range(0, 16)), 301 | Buffer.from(range(16, 32)), 302 | Buffer.from(range(0, 32)) 303 | ] 304 | ] 305 | ]); 306 | Assert.deepStrictEqual(internalRepr, expected); 307 | }); 308 | 309 | it('creates an empty Metadata object from empty headers', () => { 310 | const metadataFromHeaders = Metadata.fromHttp2Headers({}); 311 | const internalRepr = metadataFromHeaders.internalRepr; 312 | Assert.deepStrictEqual(internalRepr, new Map()); 313 | }); 314 | }); 315 | 316 | it('sets and gets metadata options', () => { 317 | const opts1 = { foo: 'bar' }; 318 | const opts2 = { baz: 'quux' }; 319 | 320 | const m = new Metadata(opts1); 321 | Assert.strictEqual(m.getOptions(), opts1); 322 | m.setOptions(opts2); 323 | Assert.strictEqual(m.getOptions(), opts2); 324 | }); 325 | }); 326 | 327 | 328 | function range (start, end) { 329 | const result = []; 330 | 331 | for (let i = start; i < end; i++) { 332 | result.push(i); 333 | } 334 | 335 | return result; 336 | } 337 | -------------------------------------------------------------------------------- /test/options.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Http2 = require('http2'); 4 | const Lab = require('@hapi/lab'); 5 | const { parseOptions } = require('../lib/options'); 6 | const { describe, it } = exports.lab = Lab.script(); 7 | 8 | 9 | describe('Options', () => { 10 | describe('parseOptions()', () => { 11 | it('parses default options', () => { 12 | Assert.deepStrictEqual(parseOptions(), { 13 | maxConcurrentStreams: undefined, 14 | maxFrameSize: Http2.getDefaultSettings().maxFrameSize, 15 | keepaliveTimeMs: 7200000, 16 | keepaliveTimeoutMs: 20000, 17 | maxSendMessageLength: Infinity, 18 | maxReceiveMessageLength: 4 * 1024 * 1024 19 | }); 20 | }); 21 | 22 | it('throws on unexpected options', () => { 23 | Assert.throws(() => { 24 | parseOptions({ foo: 'bar' }); 25 | }, /^Error: unknown option: foo$/); 26 | }); 27 | 28 | it('grpc.max_{send,receive}_message_length maps -1 to Infinity', () => { 29 | const options = parseOptions({ 30 | 'grpc.max_send_message_length': -1, 31 | 'grpc.max_receive_message_length': -1 32 | }); 33 | 34 | Assert.strictEqual(options.maxSendMessageLength, Infinity); 35 | Assert.strictEqual(options.maxReceiveMessageLength, Infinity); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/proto/echo_service.proto: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 gRPC authors. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | syntax = "proto3"; 20 | 21 | message EchoMessage { 22 | string value = 1; 23 | int32 value2 = 2; 24 | } 25 | 26 | service EchoService { 27 | rpc Echo (EchoMessage) returns (EchoMessage); 28 | 29 | rpc EchoClientStream (stream EchoMessage) returns (EchoMessage); 30 | 31 | rpc EchoServerStream (EchoMessage) returns (stream EchoMessage); 32 | 33 | rpc EchoBidiStream (stream EchoMessage) returns (stream EchoMessage); 34 | } 35 | -------------------------------------------------------------------------------- /test/proto/math.proto: -------------------------------------------------------------------------------- 1 | 2 | // Copyright 2015 gRPC authors. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | syntax = "proto3"; 17 | 18 | package math; 19 | 20 | message DivArgs { 21 | int64 dividend = 1; 22 | int64 divisor = 2; 23 | } 24 | 25 | message DivReply { 26 | int64 quotient = 1; 27 | int64 remainder = 2; 28 | } 29 | 30 | message FibArgs { 31 | int64 limit = 1; 32 | } 33 | 34 | message Num { 35 | int64 num = 1; 36 | } 37 | 38 | message FibReply { 39 | int64 count = 1; 40 | } 41 | 42 | service Math { 43 | // Div divides DivArgs.dividend by DivArgs.divisor and returns the quotient 44 | // and remainder. 45 | rpc Div (DivArgs) returns (DivReply) { 46 | } 47 | 48 | // DivMany accepts an arbitrary number of division args from the client stream 49 | // and sends back the results in the reply stream. The stream continues until 50 | // the client closes its end; the server does the same after sending all the 51 | // replies. The stream ends immediately if either end aborts. 52 | rpc DivMany (stream DivArgs) returns (stream DivReply) { 53 | } 54 | 55 | // Fib generates numbers in the Fibonacci sequence. If FibArgs.limit > 0, Fib 56 | // generates up to limit numbers; otherwise it continues until the call is 57 | // canceled. Unlike Fib above, Fib has no final FibReply. 58 | rpc Fib (FibArgs) returns (stream Num) { 59 | } 60 | 61 | // Sum sums a stream of numbers, returning the final result once the stream 62 | // is closed. 63 | rpc Sum (stream Num) returns (Num) { 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/proto/test_messages.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2015 gRPC authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | message LongValues { 18 | int64 int_64 = 1; 19 | uint64 uint_64 = 2; 20 | sint64 sint_64 = 3; 21 | fixed64 fixed_64 = 4; 22 | sfixed64 sfixed_64 = 5; 23 | } 24 | 25 | message SequenceValues { 26 | bytes bytes_field = 1; 27 | repeated int32 repeated_field = 2; 28 | } 29 | 30 | message OneOfValues { 31 | oneof oneof_choice { 32 | int32 int_choice = 1; 33 | string string_choice = 2; 34 | } 35 | } 36 | 37 | enum TestEnum { 38 | ZERO = 0; 39 | ONE = 1; 40 | TWO = 2; 41 | } 42 | 43 | message EnumValues { 44 | TestEnum enum_value = 1; 45 | } 46 | -------------------------------------------------------------------------------- /test/proto/test_service.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2015 gRPC authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | message Request { 18 | bool error = 1; 19 | string message = 2; 20 | } 21 | 22 | message Response { 23 | int32 count = 1; 24 | } 25 | 26 | service TestService { 27 | rpc Unary (Request) returns (Response) { 28 | } 29 | 30 | rpc ClientStream (stream Request) returns (Response) { 31 | } 32 | 33 | rpc ServerStream (Request) returns (stream Response) { 34 | } 35 | 36 | rpc BidiStream (stream Request) returns (stream Response) { 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/server-credentials.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Fs = require('fs'); 4 | const Path = require('path'); 5 | const Barrier = require('cb-barrier'); 6 | const Lab = require('@hapi/lab'); 7 | const Grpc = require('@grpc/grpc-js'); 8 | const { Server, ServerCredentials } = require('../lib'); 9 | const { loadProtoFile } = require('./common'); 10 | 11 | // Test shortcuts 12 | const lab = exports.lab = Lab.script(); 13 | const { describe, it, before, after } = lab; 14 | 15 | 16 | const ca = Fs.readFileSync(Path.join(__dirname, 'fixtures', 'ca.pem')); 17 | const key = Fs.readFileSync(Path.join(__dirname, 'fixtures', 'server1.key')); 18 | const cert = Fs.readFileSync(Path.join(__dirname, 'fixtures', 'server1.pem')); 19 | 20 | 21 | describe('ServerCredentials', () => { 22 | describe('createInsecure', () => { 23 | it('creates an InsecureServerCredentials instance', () => { 24 | const creds = ServerCredentials.createInsecure(); 25 | 26 | Assert.strictEqual(creds._isSecure(), false); 27 | Assert.strictEqual(creds._getSettings(), null); 28 | }); 29 | }); 30 | 31 | describe('createSsl', () => { 32 | it('accepts a buffer and array as the first two arguments', () => { 33 | const creds = ServerCredentials.createSsl(ca, []); 34 | 35 | Assert.strictEqual(creds._isSecure(), true); 36 | Assert.deepStrictEqual(creds._getSettings(), { 37 | ca, 38 | cert: [], 39 | ciphers: undefined, 40 | key: [], 41 | requestCert: false 42 | }); 43 | }); 44 | 45 | it('accepts a boolean as the third argument', () => { 46 | const creds = ServerCredentials.createSsl(ca, [], true); 47 | 48 | Assert.strictEqual(creds._isSecure(), true); 49 | Assert.deepStrictEqual(creds._getSettings(), { 50 | ca, 51 | cert: [], 52 | ciphers: undefined, 53 | key: [], 54 | requestCert: true 55 | }); 56 | }); 57 | 58 | it('accepts an object with two buffers in the second argument', () => { 59 | const keyCertPairs = [{ private_key: key, cert_chain: cert }]; 60 | const creds = ServerCredentials.createSsl(null, keyCertPairs); 61 | 62 | Assert.strictEqual(creds._isSecure(), true); 63 | Assert.deepStrictEqual(creds._getSettings(), { 64 | ca: undefined, 65 | cert: [cert], 66 | ciphers: undefined, 67 | key: [key], 68 | requestCert: false 69 | }); 70 | }); 71 | 72 | it('accepts multiple objects in the second argument', () => { 73 | const keyCertPairs = [ 74 | { private_key: key, cert_chain: cert }, 75 | { private_key: key, cert_chain: cert } 76 | ]; 77 | const creds = ServerCredentials.createSsl(null, keyCertPairs, false); 78 | 79 | Assert.strictEqual(creds._isSecure(), true); 80 | Assert.deepStrictEqual(creds._getSettings(), { 81 | ca: undefined, 82 | cert: [cert, cert], 83 | ciphers: undefined, 84 | key: [key, key], 85 | requestCert: false 86 | }); 87 | }); 88 | 89 | it('fails if the second argument is not an Array', () => { 90 | Assert.throws(() => { 91 | ServerCredentials.createSsl(ca, 'test'); 92 | }, /TypeError: keyCertPairs must be an array/); 93 | }); 94 | 95 | it('fails if the first argument is a non-Buffer value', () => { 96 | Assert.throws(() => { 97 | ServerCredentials.createSsl('test', []); 98 | }, /TypeError: rootCerts must be null or a Buffer/); 99 | }); 100 | 101 | it('fails if the third argument is a non-boolean value', () => { 102 | Assert.throws(() => { 103 | ServerCredentials.createSsl(ca, [], 'test'); 104 | }, /TypeError: checkClientCertificate must be a boolean/); 105 | }); 106 | 107 | it('fails if the array elements are not objects', () => { 108 | Assert.throws(() => { 109 | ServerCredentials.createSsl(ca, ['test']); 110 | }, /TypeError: keyCertPair\[0\] must be an object/); 111 | 112 | Assert.throws(() => { 113 | ServerCredentials.createSsl(ca, [null]); 114 | }, /TypeError: keyCertPair\[0\] must be an object/); 115 | }); 116 | 117 | it('fails if the object does not have a Buffer private_key', () => { 118 | const keyCertPairs = [{ private_key: 'test', cert_chain: cert }]; 119 | 120 | Assert.throws(() => { 121 | ServerCredentials.createSsl(null, keyCertPairs); 122 | }, /TypeError: keyCertPair\[0\].private_key must be a Buffer/); 123 | }); 124 | 125 | it('fails if the object does not have a Buffer cert_chain', () => { 126 | const keyCertPairs = [{ private_key: key, cert_chain: 'test' }]; 127 | 128 | Assert.throws(() => { 129 | ServerCredentials.createSsl(null, keyCertPairs); 130 | }, /TypeError: keyCertPair\[0\].cert_chain must be a Buffer/); 131 | }); 132 | }); 133 | 134 | it('should bind to an unused port with ssl credentials', async () => { 135 | const keyCertPairs = [{ private_key: key, cert_chain: cert }]; 136 | const creds = ServerCredentials.createSsl(ca, keyCertPairs, true); 137 | const server = new Server(); 138 | 139 | await server.bind('localhost:0', creds); 140 | server.start(); 141 | server.forceShutdown(); 142 | }); 143 | 144 | it('should bind to an unused port with insecure credentials', async () => { 145 | const server = new Server(); 146 | 147 | await server.bind('localhost:0', ServerCredentials.createInsecure()); 148 | server.start(); 149 | server.forceShutdown(); 150 | }); 151 | }); 152 | 153 | describe('client credentials', () => { 154 | let Client; 155 | let server; 156 | let port; 157 | let clientSslCreds; 158 | const clientOptions = {}; 159 | function noop () {} 160 | 161 | before(async () => { 162 | const proto = loadProtoFile(Path.join(__dirname, 'proto', 'test_service.proto')); 163 | 164 | server = new Server(); 165 | server.addService(proto.TestService.service, { 166 | unary (call, cb) { 167 | Assert.strictEqual(call.getDeadline(), Infinity); 168 | call.sendMetadata(call.metadata); 169 | cb(null, {}); 170 | }, 171 | 172 | clientStream (stream, cb) { 173 | stream.on('data', noop); 174 | stream.on('end', () => { 175 | Assert.strictEqual(stream.getDeadline(), Infinity); 176 | stream.sendMetadata(stream.metadata); 177 | cb(null, {}); 178 | }); 179 | }, 180 | 181 | serverStream (stream) { 182 | Assert.strictEqual(stream.getDeadline(), Infinity); 183 | stream.sendMetadata(stream.metadata); 184 | stream.end(); 185 | }, 186 | 187 | bidiStream (stream) { 188 | Assert.strictEqual(stream.getDeadline(), Infinity); 189 | stream.on('data', noop); 190 | stream.on('end', () => { 191 | stream.sendMetadata(stream.metadata); 192 | stream.end(); 193 | }); 194 | } 195 | }); 196 | 197 | const keyCertPairs = [{ private_key: key, cert_chain: cert }]; 198 | const creds = ServerCredentials.createSsl(null, keyCertPairs); 199 | port = await server.bind('localhost:0', creds); 200 | server.start(); 201 | 202 | Client = proto.TestService; 203 | clientSslCreds = Grpc.credentials.createSsl(ca); 204 | const hostOverride = 'foo.test.google.fr'; 205 | clientOptions['grpc.ssl_target_name_override'] = hostOverride; 206 | clientOptions['grpc.default_authority'] = hostOverride; 207 | }); 208 | 209 | after(() => { 210 | server.forceShutdown(); 211 | }); 212 | 213 | it('Should accept SSL creds for a client', () => { 214 | const barrier = new Barrier(); 215 | const client = new Client(`localhost:${port}`, clientSslCreds, clientOptions); 216 | 217 | client.unary({}, (err, data) => { 218 | Assert.ifError(err); 219 | client.close(); 220 | barrier.pass(); 221 | }); 222 | 223 | return barrier; 224 | }); 225 | 226 | describe('Per-rpc creds', () => { 227 | let client; 228 | let updaterCreds; 229 | 230 | before(() => { 231 | client = new Client(`localhost:${port}`, clientSslCreds, clientOptions); 232 | 233 | function metadataUpdater (serviceUrl, callback) { 234 | const metadata = new Grpc.Metadata(); 235 | 236 | metadata.set('plugin_key', 'plugin_value'); 237 | callback(null, metadata); 238 | } 239 | 240 | updaterCreds = Grpc.credentials.createFromMetadataGenerator(metadataUpdater); 241 | }); 242 | 243 | after(() => { 244 | client.close(); 245 | }); 246 | 247 | it('should update metadata on a unary call', () => { 248 | const barrier = new Barrier(2); 249 | const call = client.unary({}, { credentials: updaterCreds }, (err, data) => { 250 | Assert.ifError(err); 251 | barrier.pass(); 252 | }); 253 | 254 | call.on('metadata', (metadata) => { 255 | Assert.deepStrictEqual(metadata.get('plugin_key'), ['plugin_value']); 256 | barrier.pass(); 257 | }); 258 | 259 | return barrier; 260 | }); 261 | 262 | it('should update metadata on a client streaming call', () => { 263 | const barrier = new Barrier(2); 264 | const call = client.clientStream({ credentials: updaterCreds }, (err, data) => { 265 | Assert.ifError(err); 266 | barrier.pass(); 267 | }); 268 | 269 | call.on('metadata', (metadata) => { 270 | Assert.deepStrictEqual(metadata.get('plugin_key'), ['plugin_value']); 271 | barrier.pass(); 272 | }); 273 | 274 | call.end(); 275 | return barrier; 276 | }); 277 | 278 | it('should update metadata on a server streaming call', () => { 279 | const barrier = new Barrier(); 280 | const call = client.serverStream({}, { credentials: updaterCreds }); 281 | 282 | call.on('data', noop); 283 | call.on('metadata', (metadata) => { 284 | Assert.deepStrictEqual(metadata.get('plugin_key'), ['plugin_value']); 285 | barrier.pass(); 286 | }); 287 | 288 | return barrier; 289 | }); 290 | 291 | it('should update metadata on a bidi streaming call', () => { 292 | const barrier = new Barrier(); 293 | const call = client.bidiStream({ credentials: updaterCreds }); 294 | 295 | call.on('data', noop); 296 | call.on('metadata', (metadata) => { 297 | Assert.deepStrictEqual(metadata.get('plugin_key'), ['plugin_value']); 298 | barrier.pass(); 299 | }); 300 | 301 | call.end(); 302 | return barrier; 303 | }); 304 | 305 | it('should be able to use multiple plugin credentials', () => { 306 | function altMetadataUpdater (serviceUrl, callback) { 307 | const metadata = new Grpc.Metadata(); 308 | 309 | metadata.set('other_plugin_key', 'other_plugin_value'); 310 | callback(null, metadata); 311 | } 312 | 313 | const barrier = new Barrier(2); 314 | const altUpdaterCreds = Grpc.credentials.createFromMetadataGenerator(altMetadataUpdater); 315 | const combinedUpdater = Grpc.credentials.combineCallCredentials(updaterCreds, altUpdaterCreds); 316 | const call = client.unary({}, { credentials: combinedUpdater }, (err, data) => { 317 | Assert.ifError(err); 318 | barrier.pass(); 319 | }); 320 | 321 | call.on('metadata', (metadata) => { 322 | Assert.deepStrictEqual(metadata.get('plugin_key'), ['plugin_value']); 323 | Assert.deepStrictEqual(metadata.get('other_plugin_key'), ['other_plugin_value']); 324 | barrier.pass(); 325 | }); 326 | 327 | return barrier; 328 | }); 329 | }); 330 | }); 331 | -------------------------------------------------------------------------------- /test/server-resolver.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Path = require('path'); 4 | const Lab = require('@hapi/lab'); 5 | const { resolveToListenOptions } = require('../lib/server-resolver'); 6 | 7 | // Test shortcuts 8 | const { describe, it } = exports.lab = Lab.script(); 9 | 10 | 11 | // Note(cjihrig): As of @grpc/grpc-js@0.6.15, the client claims to support Unix 12 | // domain sockets. However, testing the grpc-js client did not seem to work. 13 | // Testing grpcurl with the flags `-plaintext -unix -authority 'localhost'` did 14 | // work for an insecure server. 15 | describe('Server Resolver', () => { 16 | it('resolveToListenOptions() successfully parses inputs', () => { 17 | [ 18 | [ 19 | resolveToListenOptions('dns:localhost:81', true), 20 | { host: 'localhost', port: 81 } 21 | ], 22 | [ 23 | resolveToListenOptions('dns:127.0.0.1:9999', true), 24 | { host: '127.0.0.1', port: 9999 } 25 | ], 26 | [ 27 | resolveToListenOptions('dns:foo.bar.com:9999', false), 28 | { host: 'foo.bar.com', port: 9999 } 29 | ], 30 | [ 31 | resolveToListenOptions('localhost:8080', true), 32 | { host: 'localhost', port: 8080 } 33 | ], 34 | [ 35 | resolveToListenOptions('localhost:8080', false), 36 | { host: 'localhost', port: 8080 } 37 | ], 38 | [ 39 | resolveToListenOptions('[::1]:123', true), 40 | { host: '[::1]', port: 123 } 41 | ], 42 | [ 43 | resolveToListenOptions('[::1]', true), 44 | { host: '[::1]', port: 443 } 45 | ], 46 | [ 47 | resolveToListenOptions('[::1]:80', true), 48 | { host: '[::1]', port: 80 } 49 | ], 50 | [ 51 | resolveToListenOptions('localhost', true), 52 | { host: 'localhost', port: 443 } 53 | ], 54 | [ 55 | resolveToListenOptions('localhost', false), 56 | { host: 'localhost', port: 80 } 57 | ], 58 | [ 59 | resolveToListenOptions('localhost:80', false), 60 | { host: 'localhost', port: 80 } 61 | ], 62 | [ 63 | resolveToListenOptions('localhost:80', true), 64 | { host: 'localhost', port: 80 } 65 | ], 66 | [ 67 | resolveToListenOptions('localhost:81', true), 68 | { host: 'localhost', port: 81 } 69 | ], 70 | [ 71 | resolveToListenOptions('localhost:443', false), 72 | { host: 'localhost', port: 443 } 73 | ], 74 | [ 75 | resolveToListenOptions('dns:///localhost', false), 76 | { host: 'localhost', port: 80 } 77 | ], 78 | [ 79 | resolveToListenOptions('unix:/foo/bar1', false), 80 | { path: Path.resolve('/foo/bar1') } 81 | ], 82 | [ 83 | resolveToListenOptions('unix:./foo/../baz/bar2', false), 84 | { path: Path.join(process.cwd(), 'baz', 'bar2') } 85 | ], 86 | [ 87 | resolveToListenOptions('unix:///foo/bar3', false), 88 | { path: '/foo/bar3' } 89 | ] 90 | ].forEach(([actual, expected]) => { 91 | Assert.deepStrictEqual(actual, expected); 92 | }); 93 | }); 94 | 95 | it('resolveToListenOptions() throws if unix:// path is not absolute', () => { 96 | Assert.throws(() => { 97 | resolveToListenOptions('unix://./foo', false); 98 | }, /^Error: 'unix:\/\/\.\/foo' must specify an absolute path$/); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Fs = require('fs'); 4 | const Http2 = require('http2'); 5 | const Path = require('path'); 6 | const Barrier = require('cb-barrier'); 7 | const Lab = require('@hapi/lab'); 8 | const Grpc = require('@grpc/grpc-js'); 9 | const { Server, ServerCredentials } = require('../lib'); 10 | const { loadProtoFile } = require('./common'); 11 | 12 | // Test shortcuts 13 | const lab = exports.lab = Lab.script(); 14 | const { describe, it, before, after, beforeEach, afterEach } = lab; 15 | 16 | 17 | const clientInsecureCreds = Grpc.credentials.createInsecure(); 18 | const serverInsecureCreds = ServerCredentials.createInsecure(); 19 | 20 | 21 | describe('Server', () => { 22 | describe('constructor', () => { 23 | it('should work with no arguments', () => { 24 | Assert.doesNotThrow(() => { 25 | new Server(); // eslint-disable-line no-new 26 | }); 27 | }); 28 | 29 | it('should work with an empty object argument', () => { 30 | const options = {}; 31 | 32 | Assert.doesNotThrow(() => { 33 | new Server(options); // eslint-disable-line no-new 34 | }); 35 | 36 | // The constructor applies default values. Verify that the user's 37 | // options are not overwritten. 38 | Assert.deepStrictEqual(options, {}); 39 | }); 40 | 41 | it('throws if arguments are the wrong type', () => { 42 | [null, 'foo', 5].forEach((value) => { 43 | Assert.throws(() => { 44 | new Server(value); // eslint-disable-line no-new 45 | }, /TypeError: options must be an object/); 46 | }); 47 | }); 48 | 49 | it('should be an instance of Server', () => { 50 | const server = new Server(); 51 | 52 | Assert(server instanceof Server); 53 | }); 54 | }); 55 | 56 | describe('bind', () => { 57 | it('uses insecure credentials by default', async () => { 58 | const server = new Server(); 59 | 60 | server.bindAsync = function (port, creds, callback) { 61 | Assert.strictEqual(creds._isSecure(), false); 62 | callback(null, 1000); 63 | }; 64 | 65 | await server.bind('localhost:0'); 66 | await server.bind('localhost:0', null); 67 | }); 68 | 69 | it('handles errors during binding', async () => { 70 | const server = new Server(); 71 | 72 | server.bindAsync = function (port, creds, callback) { 73 | callback(new Error('test error'), -1); 74 | }; 75 | 76 | await Assert.rejects(async () => { 77 | await server.bind('localhost:0'); 78 | }, /^Error: test error$/); 79 | }); 80 | }); 81 | 82 | describe('bindAsync', () => { 83 | it('binds with insecure credentials', () => { 84 | const server = new Server(); 85 | const barrier = new Barrier(); 86 | 87 | server.bindAsync('localhost:0', serverInsecureCreds, (err, port) => { 88 | Assert.ifError(err); 89 | Assert(typeof port === 'number' && port > 0); 90 | server.tryShutdown(barrier.pass); 91 | }); 92 | 93 | return barrier; 94 | }); 95 | 96 | it('binds with secure credentials', () => { 97 | const server = new Server(); 98 | const barrier = new Barrier(); 99 | const ca = Fs.readFileSync(Path.join(__dirname, 'fixtures', 'ca.pem')); 100 | const key = Fs.readFileSync(Path.join(__dirname, 'fixtures', 'server1.key')); 101 | const cert = Fs.readFileSync(Path.join(__dirname, 'fixtures', 'server1.pem')); 102 | 103 | const creds = ServerCredentials.createSsl(ca, 104 | [{ private_key: key, cert_chain: cert }], true); 105 | 106 | server.bindAsync('localhost:0', creds, (err, port) => { 107 | Assert.ifError(err); 108 | Assert(typeof port === 'number' && port > 0); 109 | server.tryShutdown(barrier.pass); 110 | }); 111 | 112 | return barrier; 113 | }); 114 | 115 | it('throws if bind is called after the server is started', () => { 116 | const server = new Server(); 117 | const barrier = new Barrier(); 118 | 119 | server.bindAsync('localhost:0', serverInsecureCreds, (err, port) => { 120 | Assert.ifError(err); 121 | server.start(); 122 | Assert.throws(() => { 123 | server.bindAsync('localhost:0', serverInsecureCreds, () => {}); 124 | }, /server is already started/); 125 | server.tryShutdown(barrier.pass); 126 | }); 127 | 128 | return barrier; 129 | }); 130 | 131 | it('handles errors while trying to bind', () => { 132 | const server1 = new Server(); 133 | const server2 = new Server(); 134 | const barrier = new Barrier(); 135 | 136 | server1.bindAsync('localhost:0', serverInsecureCreds, (err, port) => { 137 | Assert.ifError(err); 138 | Assert(typeof port === 'number' && port > 0); 139 | server2.bindAsync(`localhost:${port}`, serverInsecureCreds, (err, port) => { 140 | Assert.strictEqual(err.code, 'EADDRINUSE'); 141 | Assert.strictEqual(port, -1); 142 | server1.tryShutdown(() => { 143 | server2.tryShutdown(barrier.pass); 144 | }); 145 | }); 146 | }); 147 | 148 | return barrier; 149 | }); 150 | 151 | it('throws on invalid inputs', () => { 152 | const server = new Server(); 153 | 154 | Assert.throws(() => { 155 | server.bindAsync(null, serverInsecureCreds, () => {}); 156 | }, /port must be a string/); 157 | 158 | Assert.throws(() => { 159 | server.bindAsync('localhost:0', null, () => {}); 160 | }, /creds must be an object/); 161 | 162 | Assert.throws(() => { 163 | server.bindAsync('localhost:0', 'foo', () => {}); 164 | }, /creds must be an object/); 165 | 166 | Assert.throws(() => { 167 | server.bindAsync('localhost:0', serverInsecureCreds, null); 168 | }, /callback must be a function/); 169 | }); 170 | }); 171 | 172 | describe('start', () => { 173 | let server; 174 | 175 | beforeEach(async () => { 176 | server = new Server(); 177 | await server.bind(8000, ServerCredentials.createInsecure()); 178 | }); 179 | 180 | afterEach(() => { 181 | server.forceShutdown(); 182 | }); 183 | 184 | it('should start without error', () => { 185 | Assert.doesNotThrow(() => { 186 | server.start(); 187 | }); 188 | }); 189 | 190 | it('should error if started twice', () => { 191 | server.start(); 192 | Assert.throws(() => { 193 | server.start(); 194 | }, /server is already started/); 195 | }); 196 | 197 | it('should error if bind is called after the server starts', () => { 198 | server.start(); 199 | Assert.rejects(async () => { 200 | await server.bind('localhost:0', serverInsecureCreds); 201 | }, /server is already started/); 202 | }); 203 | 204 | it('throws if the server is not bound', () => { 205 | const server = new Server(); 206 | 207 | Assert.throws(() => { 208 | server.start(); 209 | }, /server must be bound in order to start/); 210 | }); 211 | }); 212 | 213 | describe('Server.prototype.unregister', () => { 214 | const mathProtoFile = Path.join(__dirname, 'proto', 'math.proto'); 215 | const MathClient = loadProtoFile(mathProtoFile).math.Math; 216 | const mathServiceAttrs = MathClient.service; 217 | 218 | let server; 219 | let client; 220 | 221 | beforeEach(() => { 222 | const barrier = new Barrier(); 223 | server = new Server(); 224 | server.addService(mathServiceAttrs, { 225 | div (call, callback) { 226 | callback(null, { quotient: '42' }); 227 | } 228 | }); 229 | server.bindAsync('localhost:0', serverInsecureCreds, (err, port) => { 230 | Assert.ifError(err); 231 | client = new MathClient(`localhost:${port}`, clientInsecureCreds); 232 | server.start(); 233 | barrier.pass(); 234 | }); 235 | return barrier; 236 | }); 237 | 238 | afterEach(() => { 239 | client.close(); 240 | server.forceShutdown(); 241 | }); 242 | 243 | it('removes existing handler', () => { 244 | const barrier = new Barrier(); 245 | 246 | client.div({ divisor: 4, dividend: 3 }, (err, result) => { 247 | Assert.ifError(err); 248 | Assert.deepStrictEqual(result, { quotient: '42', remainder: '0' }); 249 | 250 | const name = mathServiceAttrs['Div'].path; 251 | Assert.strictEqual(server.unregister(name), true); 252 | 253 | client.div({ divisor: 4, dividend: 3 }, (err) => { 254 | Assert(err); 255 | Assert.strictEqual(err.code, Grpc.status.UNIMPLEMENTED); 256 | barrier.pass(); 257 | }); 258 | }); 259 | 260 | return barrier; 261 | }); 262 | 263 | it('returns false for unknown handler', () => { 264 | Assert.strictEqual(server.unregister('does-not-exist'), false); 265 | }); 266 | }); 267 | 268 | describe('Server.prototype.addService', () => { 269 | const mathProtoFile = Path.join(__dirname, 'proto', 'math.proto'); 270 | const MathClient = loadProtoFile(mathProtoFile).math.Math; 271 | const mathServiceAttrs = MathClient.service; 272 | const dummyImpls = { 273 | div () {}, 274 | divMany () {}, 275 | fib () {}, 276 | sum () {} 277 | }; 278 | const altDummyImpls = { 279 | Div () {}, 280 | DivMany () {}, 281 | Fib () {}, 282 | Sum () {} 283 | }; 284 | let server; 285 | 286 | beforeEach(() => { 287 | server = new Server(); 288 | }); 289 | 290 | afterEach(() => { 291 | server.forceShutdown(); 292 | }); 293 | 294 | it('Should succeed with a single service', () => { 295 | Assert.doesNotThrow(() => { 296 | server.addService(mathServiceAttrs, dummyImpls); 297 | }); 298 | }); 299 | 300 | it('Should fail with conflicting method names', () => { 301 | server.addService(mathServiceAttrs, dummyImpls); 302 | Assert.throws(() => { 303 | server.addService(mathServiceAttrs, dummyImpls); 304 | }); 305 | }); 306 | 307 | it('Should allow method names as originally written', () => { 308 | Assert.doesNotThrow(() => { 309 | server.addService(mathServiceAttrs, altDummyImpls); 310 | }); 311 | }); 312 | 313 | it('Should succeed even if the server has already been started', async () => { 314 | await server.bind('localhost:0', serverInsecureCreds); 315 | server.start(); 316 | server.addService(mathServiceAttrs, dummyImpls); 317 | }); 318 | 319 | it('fails trying to add an empty service', () => { 320 | Assert.throws(() => { 321 | server.addService({}, {}); 322 | }, /^Error: Cannot add an empty service to a server$/); 323 | }); 324 | 325 | it('fails if both inputs are not objects', () => { 326 | [ 327 | [null, {}], 328 | ['foo', {}], 329 | [{}, null], 330 | [{}, 'foo'] 331 | ].forEach((inputs) => { 332 | Assert.throws(() => { 333 | server.addService(inputs[0], inputs[1]); 334 | }); 335 | }); 336 | }); 337 | 338 | describe('Default handlers', () => { 339 | let client; 340 | 341 | beforeEach(async () => { 342 | server.addService(mathServiceAttrs, {}); 343 | const port = await server.bind('localhost:0', serverInsecureCreds); 344 | client = new MathClient(`localhost:${port}`, clientInsecureCreds); 345 | server.start(); 346 | }); 347 | 348 | afterEach(() => { 349 | client.close(); 350 | server.forceShutdown(); 351 | }); 352 | 353 | it('should respond to a unary call with UNIMPLEMENTED', () => { 354 | const barrier = new Barrier(); 355 | 356 | client.div({ divisor: 4, dividend: 3 }, (error, response) => { 357 | Assert(error); 358 | Assert.strictEqual(error.code, Grpc.status.UNIMPLEMENTED); 359 | Assert.strictEqual(error.details, 'The server does not implement the method Div'); 360 | barrier.pass(); 361 | }); 362 | 363 | return barrier; 364 | }); 365 | 366 | it('should respond to a client stream with UNIMPLEMENTED', () => { 367 | const barrier = new Barrier(); 368 | const call = client.sum((error, respones) => { 369 | Assert(error); 370 | Assert.strictEqual(error.code, Grpc.status.UNIMPLEMENTED); 371 | Assert.strictEqual(error.details, 'The server does not implement the method Sum'); 372 | barrier.pass(); 373 | }); 374 | 375 | call.end(); 376 | return barrier; 377 | }); 378 | 379 | it('should respond to a server stream with UNIMPLEMENTED', () => { 380 | const barrier = new Barrier(); 381 | const call = client.fib({ limit: 5 }); 382 | 383 | call.on('data', (value) => { 384 | Assert.fail('No messages expected'); 385 | }); 386 | 387 | call.on('error', (err) => { 388 | Assert(err); 389 | Assert.strictEqual(err.code, Grpc.status.UNIMPLEMENTED); 390 | Assert.strictEqual(err.details, 'The server does not implement the method Fib'); 391 | barrier.pass(); 392 | }); 393 | 394 | return barrier; 395 | }); 396 | 397 | it('should respond to a bidi call with UNIMPLEMENTED', () => { 398 | const barrier = new Barrier(); 399 | const call = client.divMany(); 400 | 401 | call.on('data', (value) => { 402 | Assert.fail('No messages expected'); 403 | }); 404 | 405 | call.on('error', (err) => { 406 | Assert.strictEqual(err.code, Grpc.status.UNIMPLEMENTED); 407 | Assert.strictEqual(err.details, 'The server does not implement the method DivMany'); 408 | barrier.pass(); 409 | }); 410 | 411 | call.end(); 412 | 413 | return barrier; 414 | }); 415 | }); 416 | }); 417 | 418 | describe('Server.prototype.removeService', () => { 419 | const mathProtoFile = Path.join(__dirname, 'proto', 'math.proto'); 420 | const MathClient = loadProtoFile(mathProtoFile).math.Math; 421 | const mathServiceAttrs = MathClient.service; 422 | const dummyImpls = { 423 | div () {}, 424 | divMany () {}, 425 | fib () {}, 426 | sum () {} 427 | }; 428 | 429 | let server; 430 | let client; 431 | 432 | beforeEach(() => { 433 | const barrier = new Barrier(); 434 | server = new Server(); 435 | server.addService(mathServiceAttrs, dummyImpls); 436 | server.bindAsync('localhost:0', serverInsecureCreds, (err, port) => { 437 | Assert.ifError(err); 438 | client = new MathClient(`localhost:${port}`, clientInsecureCreds); 439 | server.start(); 440 | barrier.pass(); 441 | }); 442 | return barrier; 443 | }); 444 | 445 | afterEach(() => { 446 | client.close(); 447 | server.forceShutdown(); 448 | }); 449 | 450 | it('removes a service', () => { 451 | const barrier = new Barrier(); 452 | server.removeService(mathServiceAttrs); 453 | 454 | let methodsVerifiedCount = 0; 455 | const methodsToVerify = Object.keys(mathServiceAttrs); 456 | 457 | const assertFailsWithUnimplementedError = (err) => { 458 | Assert(err); 459 | Assert.strictEqual(err.code, Grpc.status.UNIMPLEMENTED); 460 | methodsVerifiedCount++; 461 | if (methodsVerifiedCount === methodsToVerify.length) { 462 | barrier.pass(); 463 | } 464 | }; 465 | 466 | methodsToVerify.forEach((method) => { 467 | const call = client[method]({}, assertFailsWithUnimplementedError); 468 | call.on('error', assertFailsWithUnimplementedError); 469 | }); 470 | 471 | return barrier; 472 | }); 473 | 474 | it('fails if input is not an object', () => { 475 | [undefined, null, 'foo', 5, true].forEach((input) => { 476 | Assert.throws(() => { 477 | server.removeService(input); 478 | }, /^Error: removeService requires an object argument$/); 479 | }); 480 | }); 481 | }); 482 | 483 | describe('Server.prototype.tryShutdown', () => { 484 | it('calls back without an error if the server is not bound', () => { 485 | const barrier = new Barrier(); 486 | const server = new Server(); 487 | 488 | server.tryShutdown((err) => { 489 | Assert.ifError(err); 490 | barrier.pass(); 491 | }); 492 | 493 | return barrier; 494 | }); 495 | 496 | it('is idempotent with itself', async () => { 497 | const barrier = new Barrier(); 498 | const server = new Server(); 499 | 500 | await server.bind('localhost:0', serverInsecureCreds); 501 | server.start(); 502 | server.tryShutdown((err) => { 503 | Assert.ifError(err); 504 | server.tryShutdown((err) => { 505 | Assert.ifError(err); 506 | barrier.pass(); 507 | }); 508 | }); 509 | 510 | return barrier; 511 | }); 512 | 513 | it('is idempotent with forceShutdown()', async () => { 514 | const barrier = new Barrier(); 515 | const server = new Server(); 516 | 517 | await server.bind('localhost:0', serverInsecureCreds); 518 | server.start(); 519 | server.tryShutdown((err) => { 520 | Assert.ifError(err); 521 | server.forceShutdown(); 522 | barrier.pass(); 523 | }); 524 | 525 | return barrier; 526 | }); 527 | }); 528 | 529 | describe('Server.prototype.forceShutdown', () => { 530 | it('does not throw if the server is not bound', () => { 531 | const server = new Server(); 532 | 533 | server.forceShutdown(); 534 | }); 535 | 536 | it('is idempotent with itself', async () => { 537 | const server = new Server(); 538 | 539 | await server.bind('localhost:0', serverInsecureCreds); 540 | server.start(); 541 | server.forceShutdown(); 542 | server.forceShutdown(); 543 | }); 544 | 545 | it('is idempotent with tryShutdown()', async () => { 546 | const barrier = new Barrier(); 547 | const server = new Server(); 548 | 549 | await server.bind('localhost:0', serverInsecureCreds); 550 | server.start(); 551 | server.forceShutdown(); 552 | server.tryShutdown((err) => { 553 | Assert.ifError(err); 554 | barrier.pass(); 555 | }); 556 | 557 | return barrier; 558 | }); 559 | 560 | it('forcefully closes connections', async () => { 561 | const barrier = new Barrier(); 562 | const server = new Server(); 563 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 564 | const { EchoService } = loadProtoFile(protoFile); 565 | let calledForceShutdown = false; 566 | let client; // eslint-disable-line prefer-const 567 | 568 | server.addService(EchoService.service, { 569 | echoBidiStream (stream) { 570 | // Verify that forceShutdown() triggers tryShutdown(). 571 | server.tryShutdown(() => { 572 | Assert.strictEqual(calledForceShutdown, true); 573 | client.close(); 574 | barrier.pass(); 575 | }); 576 | 577 | stream.write({}); 578 | } 579 | }); 580 | 581 | const port = await server.bind('localhost:0', serverInsecureCreds); 582 | client = new EchoService(`localhost:${port}`, clientInsecureCreds); 583 | server.start(); 584 | const stream = client.echoBidiStream(); 585 | 586 | stream.on('data', (message) => { 587 | Assert.deepStrictEqual(message, { value: '', value2: 0 }); 588 | server.forceShutdown(); 589 | calledForceShutdown = true; 590 | }); 591 | 592 | stream.on('error', (err) => { 593 | Assert(err); 594 | }); 595 | 596 | return barrier; 597 | }); 598 | }); 599 | 600 | describe('Echo service', () => { 601 | let server; 602 | let client; 603 | 604 | before(async () => { 605 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 606 | const { EchoService } = loadProtoFile(protoFile); 607 | 608 | server = new Server(); 609 | server.addService(EchoService.service, { 610 | echo (call, callback) { 611 | callback(null, call.request); 612 | } 613 | }); 614 | 615 | const port = await server.bind('localhost:0', serverInsecureCreds); 616 | client = new EchoService(`localhost:${port}`, clientInsecureCreds); 617 | server.start(); 618 | }); 619 | 620 | after(() => { 621 | client.close(); 622 | server.forceShutdown(); 623 | }); 624 | 625 | it('should echo the received message directly', () => { 626 | const barrier = new Barrier(); 627 | 628 | client.echo({ value: 'test value', value2: 3 }, (error, response) => { 629 | Assert.ifError(error); 630 | Assert.deepStrictEqual(response, { value: 'test value', value2: 3 }); 631 | barrier.pass(); 632 | }); 633 | 634 | return barrier; 635 | }); 636 | }); 637 | 638 | describe('Generic client and server', () => { 639 | function toString (val) { 640 | return val.toString(); 641 | } 642 | 643 | function toBuffer (str) { 644 | return Buffer.from(str); 645 | } 646 | 647 | function capitalize (str) { 648 | return str.charAt(0).toUpperCase() + str.slice(1); 649 | } 650 | 651 | const stringServiceAttrs = { 652 | capitalize: { 653 | path: '/string/capitalize', 654 | requestStream: false, 655 | responseStream: false, 656 | requestSerialize: toBuffer, 657 | requestDeserialize: toString, 658 | responseSerialize: toBuffer, 659 | responseDeserialize: toString 660 | } 661 | }; 662 | 663 | describe('String client and server', () => { 664 | let client; 665 | let server; 666 | 667 | before(async () => { 668 | server = new Server(); 669 | 670 | server.addService(stringServiceAttrs, { 671 | capitalize (call, callback) { 672 | callback(null, capitalize(call.request)); 673 | } 674 | }); 675 | 676 | const port = await server.bind('localhost:0', serverInsecureCreds); 677 | server.start(); 678 | const Client = Grpc.makeGenericClientConstructor(stringServiceAttrs); 679 | client = new Client(`localhost:${port}`, clientInsecureCreds); 680 | }); 681 | 682 | after(() => { 683 | client.close(); 684 | server.forceShutdown(); 685 | }); 686 | 687 | it('Should respond with a capitalized string', () => { 688 | const barrier = new Barrier(); 689 | 690 | client.capitalize('abc', (err, response) => { 691 | Assert.ifError(err); 692 | Assert.strictEqual(response, 'Abc'); 693 | barrier.pass(); 694 | }); 695 | 696 | return barrier; 697 | }); 698 | }); 699 | }); 700 | 701 | it('throws when unimplemented methods are called', () => { 702 | const server = new Server(); 703 | 704 | Assert.throws(() => { 705 | server.addProtoService(); 706 | }, /not implemented. use addService\(\) instead/); 707 | 708 | Assert.throws(() => { 709 | server.addHttp2Port(); 710 | }, /not implemented/); 711 | }); 712 | 713 | it('responds with HTTP status of 415 on invalid content-type', async () => { 714 | const barrier = new Barrier(); 715 | const server = new Server(); 716 | const port = await server.bind('localhost:0', serverInsecureCreds); 717 | const client = Http2.connect(`http://localhost:${port}`); 718 | let count = 0; 719 | 720 | server.start(); 721 | 722 | function makeRequest (headers) { 723 | const req = client.request(headers); 724 | let statusCode; 725 | 726 | req.on('response', (headers) => { 727 | statusCode = headers[Http2.constants.HTTP2_HEADER_STATUS]; 728 | }); 729 | 730 | req.on('end', () => { 731 | Assert.strictEqual(statusCode, Http2.constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE); 732 | count++; 733 | if (count === 2) { 734 | client.close(); 735 | server.tryShutdown(barrier.pass); 736 | } 737 | }); 738 | 739 | req.end(); 740 | } 741 | 742 | // Missing Content-Type header. 743 | makeRequest({ ':path': '/' }); 744 | // Invalid Content-Type header. 745 | makeRequest({ ':path': '/', 'content-type': 'application/not-grpc' }); 746 | return barrier; 747 | }); 748 | 749 | it('rejects connections if the server is bound but not started', async () => { 750 | const barrier = new Barrier(); 751 | const server = new Server(); 752 | const port = await server.bind('localhost:0', serverInsecureCreds); 753 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 754 | const { EchoService } = loadProtoFile(protoFile); 755 | const client = new EchoService(`localhost:${port}`, clientInsecureCreds); 756 | 757 | client.echo({ value: 'test value', value2: 3 }, (error, response) => { 758 | Assert.strictEqual(error.code, Grpc.status.UNAVAILABLE); 759 | Assert.strictEqual(response, undefined); 760 | client.close(); 761 | server.tryShutdown(barrier.pass); 762 | }); 763 | 764 | return barrier; 765 | }); 766 | 767 | it('returns UNIMPLEMENTED on 404', async () => { 768 | const barrier = new Barrier(); 769 | const server = new Server(); 770 | const port = await server.bind('localhost:0', serverInsecureCreds); 771 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 772 | const { EchoService } = loadProtoFile(protoFile); 773 | const client = new EchoService(`localhost:${port}`, clientInsecureCreds); 774 | 775 | server.start(); 776 | client.echo({ value: 'test value', value2: 3 }, (error, response) => { 777 | Assert.strictEqual(error.code, Grpc.status.UNIMPLEMENTED); 778 | Assert.strictEqual(error.details, 'The server does not implement the method /EchoService/Echo'); 779 | Assert.strictEqual(response, undefined); 780 | client.close(); 781 | server.tryShutdown(barrier.pass); 782 | }); 783 | 784 | return barrier; 785 | }); 786 | 787 | it('sends keepalive pings', async () => { 788 | const barrier = new Barrier(); 789 | const server = new Server({ 790 | 'grpc.keepalive_time_ms': 10, 791 | 'grpc.keepalive_timeout_ms': 1 792 | }); 793 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 794 | const { EchoService } = loadProtoFile(protoFile); 795 | 796 | server.addService(EchoService.service, { 797 | echoBidiStream (stream) { 798 | stream.on('data', (data) => { 799 | Assert.fail('no data events expected on server'); 800 | }); 801 | } 802 | }); 803 | 804 | const port = await server.bind('localhost:0', serverInsecureCreds); 805 | const client = new EchoService(`localhost:${port}`, clientInsecureCreds); 806 | server.start(); 807 | const stream = client.echoBidiStream(); 808 | 809 | stream.on('close', () => { 810 | Assert.fail('close event not expected on client'); 811 | }); 812 | 813 | stream.on('end', () => { 814 | Assert.fail('end event not expected on client'); 815 | }); 816 | 817 | stream.on('error', (err) => { 818 | Assert(err); 819 | client.close(); 820 | server.tryShutdown(barrier.pass); 821 | }); 822 | 823 | return barrier; 824 | }); 825 | 826 | it('handles multiple messages in a single frame', async () => { 827 | const barrier = new Barrier(); 828 | const server = new Server(); 829 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 830 | const { EchoService } = loadProtoFile(protoFile); 831 | let receivedCount = 0; 832 | 833 | server.addService(EchoService.service, { 834 | echoBidiStream (stream) { 835 | stream.pause(); 836 | 837 | setImmediate(() => { 838 | stream.resume(); 839 | }); 840 | 841 | stream.on('data', (data) => { 842 | Assert.deepStrictEqual(data, { value: '', value2: 0 }); 843 | receivedCount++; 844 | 845 | // The value 20 is dependent on the number of bytes that each message 846 | // serializes to. If this test ever starts failing, it's likely due to 847 | // a change in protobuf.js, and the expected number may change. 848 | if (receivedCount === 20) { 849 | stream.end(); 850 | client.close(); // eslint-disable-line no-use-before-define 851 | server.tryShutdown(barrier.pass); 852 | } 853 | }); 854 | } 855 | }); 856 | 857 | const port = await server.bind('localhost:0', serverInsecureCreds); 858 | server.start(); 859 | 860 | const client = Http2.connect(`http://localhost:${port}`); 861 | const req = client.request({ 862 | [Http2.constants.HTTP2_HEADER_PATH]: '/EchoService/EchoBidiStream', 863 | [Http2.constants.HTTP2_HEADER_METHOD]: 'POST', 864 | [Http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/grpc' 865 | }); 866 | 867 | req.write(Buffer.alloc(100)); 868 | req.end(); 869 | return barrier; 870 | }); 871 | 872 | it('stream handlers can serialize and deserialize messages', async () => { 873 | const barrier = new Barrier(); 874 | const server = new Server(); 875 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 876 | const { EchoService } = loadProtoFile(protoFile); 877 | 878 | server.addService(EchoService.service, { 879 | echoBidiStream (stream) { 880 | stream.on('data', (data) => { 881 | Assert.deepStrictEqual(data, { value: '', value2: 0 }); 882 | const bytes = stream.serialize(data); 883 | const message = stream.deserialize(bytes); 884 | 885 | // Verify serialize-deserialize functionality. 886 | Assert(bytes instanceof Buffer); 887 | Assert.deepStrictEqual(message, data); 888 | 889 | // Verify handling of edge cases. 890 | Assert.strictEqual(stream.serialize(null), null); 891 | Assert.strictEqual(stream.serialize(undefined), null); 892 | Assert.strictEqual(stream.deserialize(null), null); 893 | Assert.strictEqual(stream.deserialize(undefined), null); 894 | stream.end(); 895 | }); 896 | } 897 | }); 898 | 899 | const port = await server.bind('localhost:0', serverInsecureCreds); 900 | const client = new EchoService(`localhost:${port}`, clientInsecureCreds); 901 | server.start(); 902 | const stream = client.echoBidiStream(); 903 | 904 | stream.write({}); 905 | stream.on('status', () => { 906 | client.close(); 907 | server.forceShutdown(); 908 | barrier.pass(); 909 | }); 910 | stream.end(); 911 | 912 | return barrier; 913 | }); 914 | 915 | it('can serve traffic on multiple ports', async () => { 916 | const barrier = new Barrier(); 917 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 918 | const { EchoService } = loadProtoFile(protoFile); 919 | const server = new Server(); 920 | 921 | server.addService(EchoService.service, { 922 | echo (call, callback) { 923 | callback(null, call.request); 924 | } 925 | }); 926 | 927 | const port1 = await server.bind('localhost:0', serverInsecureCreds); 928 | const port2 = await server.bind('localhost:0', serverInsecureCreds); 929 | Assert.notStrictEqual(port1, port2); 930 | server.start(); 931 | const client1 = new EchoService(`localhost:${port1}`, clientInsecureCreds); 932 | const client2 = new EchoService(`localhost:${port2}`, clientInsecureCreds); 933 | 934 | client1.echo({ value: 'test value', value2: 3 }, (error, response) => { 935 | Assert.ifError(error); 936 | Assert.deepStrictEqual(response, { value: 'test value', value2: 3 }); 937 | client2.echo({ value: 'test two', value2: 99 }, (error, response) => { 938 | Assert.ifError(error); 939 | Assert.deepStrictEqual(response, { value: 'test two', value2: 99 }); 940 | client1.close(); 941 | client2.close(); 942 | server.forceShutdown(); 943 | barrier.pass(); 944 | }); 945 | }); 946 | 947 | return barrier; 948 | }); 949 | 950 | describe('Unix Domain Socket Support', () => { 951 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 952 | const { EchoService } = loadProtoFile(protoFile); 953 | const tmpDir = Path.join(__dirname, '.tmpdir'); 954 | let counter = 0; 955 | 956 | async function runTest (path) { 957 | const barrier = new Barrier(); 958 | const server = new Server(); 959 | 960 | server.addService(EchoService.service, { 961 | echo (call, callback) { 962 | Assert.strictEqual(call.getPeer(), 'unknown'); 963 | callback(null, call.request); 964 | } 965 | }); 966 | 967 | const port = await server.bind(path, serverInsecureCreds); 968 | Assert.strictEqual(port, undefined); 969 | server.start(); 970 | const client = new EchoService(path, clientInsecureCreds); 971 | 972 | client.echo({ value: 'test value', value2: 42 }, (error, response) => { 973 | Assert.ifError(error); 974 | Assert.deepStrictEqual(response, { value: 'test value', value2: 42 }); 975 | client.close(); 976 | server.forceShutdown(); 977 | barrier.pass(); 978 | }); 979 | 980 | return barrier; 981 | } 982 | 983 | function getAbsolutePath () { 984 | const file = Path.join(tmpDir, `test-sock-${counter++}`); 985 | 986 | if (process.platform === 'win32') { 987 | return Path.join('\\\\.\\pipe\\', file); 988 | } 989 | 990 | return file; 991 | } 992 | 993 | function getRelativePath () { 994 | const file = Path.join(Path.relative(process.cwd(), tmpDir), 995 | `test-sock-${counter++}`); 996 | 997 | if (process.platform === 'win32') { 998 | return Path.join('\\\\.\\pipe\\', file); 999 | } 1000 | 1001 | return file; 1002 | } 1003 | 1004 | function cleanup () { 1005 | try { 1006 | Fs.readdirSync(tmpDir).forEach((entry) => { 1007 | try { 1008 | Fs.unlinkSync(entry); 1009 | } catch (ignoreErr) {} 1010 | }); 1011 | 1012 | Fs.rmdirSync(tmpDir); 1013 | } catch (ignoreErr) {} 1014 | } 1015 | 1016 | before(() => { 1017 | try { 1018 | cleanup(); 1019 | Fs.mkdirSync(tmpDir); 1020 | } catch (ignoreErr) {} 1021 | }); 1022 | 1023 | after(() => { 1024 | cleanup(); 1025 | }); 1026 | 1027 | it('handles unix: followed by an absolute path', async () => { 1028 | const path = `unix:${getAbsolutePath()}`; 1029 | await runTest(path); 1030 | }); 1031 | 1032 | it('handles unix: followed by a relative path', async () => { 1033 | const path = `unix:${getRelativePath()}`; 1034 | await runTest(path); 1035 | }); 1036 | 1037 | // Skip on Windows. The client no longer seems to connect. 1038 | it('handles unix:// followed by an absolute path', { skip: process.platform === 'win32' }, async () => { 1039 | const path = `unix://${getAbsolutePath()}`; 1040 | await runTest(path); 1041 | }); 1042 | 1043 | // Skip on Windows, as the pipe prefix is required, and makes it an absolute path. 1044 | it('throws if unix:// is followed by a relative path', { skip: process.platform === 'win32' }, async () => { 1045 | const path = `unix://${getRelativePath()}`; 1046 | await Assert.rejects(async () => { 1047 | await runTest(path); 1048 | }, /must specify an absolute path/); 1049 | }); 1050 | }); 1051 | 1052 | describe('Maximum Message Size', () => { 1053 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 1054 | const { EchoService } = loadProtoFile(protoFile); 1055 | 1056 | async function runTest (settings) { 1057 | const barrier = new Barrier(); 1058 | const server = new Server(settings); 1059 | 1060 | server.addService(EchoService.service, { 1061 | echo (call, callback) { 1062 | callback(null, { value: call.request.value }); 1063 | }, 1064 | echoBidiStream (stream) { 1065 | stream.on('data', (chunk) => { 1066 | stream.write({ value: chunk.value }); 1067 | }); 1068 | } 1069 | }); 1070 | 1071 | const port = await server.bind('localhost:0', serverInsecureCreds); 1072 | server.start(); 1073 | const client = new EchoService(`localhost:${port}`, clientInsecureCreds); 1074 | 1075 | // Test a unary send/receive. 1076 | client.echo({ value: 'a' }, (error, response) => { 1077 | Assert.strictEqual(error.code, Grpc.status.RESOURCE_EXHAUSTED); 1078 | if (settings['grpc.max_receive_message_length']) { 1079 | Assert.strictEqual(error.details, 'Received message larger than max (8 vs. 1)'); 1080 | } else { 1081 | Assert.strictEqual(error.details, 'Sent message larger than max (8 vs. 1)'); 1082 | } 1083 | Assert.strictEqual(response, undefined); 1084 | 1085 | // Test a streaming send/receive. 1086 | const call = client.echoBidiStream(); 1087 | call.on('data', () => { throw new Error('should not happen'); }); 1088 | call.on('error', (error) => { 1089 | Assert.strictEqual(error.code, Grpc.status.RESOURCE_EXHAUSTED); 1090 | if (settings['grpc.max_receive_message_length']) { 1091 | Assert.strictEqual(error.details, 'Received message larger than max (9 vs. 1)'); 1092 | } else { 1093 | Assert.strictEqual(error.details, 'Sent message larger than max (9 vs. 1)'); 1094 | } 1095 | client.close(); 1096 | server.forceShutdown(); 1097 | barrier.pass(); 1098 | }); 1099 | 1100 | call.write({ value: 'bc' }); 1101 | }); 1102 | 1103 | return barrier; 1104 | } 1105 | 1106 | it('enforces maximum received message length', async () => { 1107 | await runTest({ 'grpc.max_receive_message_length': 1 }); 1108 | }); 1109 | 1110 | it('enforces maximum sent message length', async () => { 1111 | await runTest({ 'grpc.max_send_message_length': 1 }); 1112 | }); 1113 | }); 1114 | 1115 | 1116 | describe('No stream end events on error', () => { 1117 | const protoFile = Path.join(__dirname, 'proto', 'echo_service.proto'); 1118 | const { EchoService } = loadProtoFile(protoFile); 1119 | 1120 | async function getTestSetup () { 1121 | const barrier = new Barrier(); 1122 | const server = new Server(); 1123 | 1124 | server.addService(EchoService.service, { 1125 | echoClientStream (stream, callback) { 1126 | stream.on('end', () => { 1127 | throw new Error('should not happen'); 1128 | }); 1129 | 1130 | stream.on('data', (chunk) => { 1131 | throw new Error('client-stream-error'); 1132 | }); 1133 | }, 1134 | echoBidiStream (stream) { 1135 | stream.on('end', () => { 1136 | throw new Error('should not happen'); 1137 | }); 1138 | 1139 | stream.on('data', (chunk) => { 1140 | throw new Error('bidi-stream-error'); 1141 | }); 1142 | } 1143 | }); 1144 | 1145 | const port = await server.bind('localhost:0', serverInsecureCreds); 1146 | server.start(); 1147 | const client = new EchoService(`localhost:${port}`, clientInsecureCreds); 1148 | return { barrier, client, server }; 1149 | } 1150 | 1151 | it('does not emit end event on server for client stream', async () => { 1152 | const { barrier, client, server } = await getTestSetup(); 1153 | const stream = client.echoClientStream((err, data) => { 1154 | client.close(); 1155 | server.forceShutdown(); 1156 | Assert.strictEqual(err.details, 'client-stream-error'); 1157 | Assert.strictEqual(data, undefined); 1158 | barrier.pass(); 1159 | }); 1160 | 1161 | stream.write({}); 1162 | return barrier; 1163 | }); 1164 | 1165 | it('does not emit end event on server for bidi stream', async () => { 1166 | const { barrier, client, server } = await getTestSetup(); 1167 | const stream = client.echoBidiStream(); 1168 | 1169 | stream.on('error', (err) => { 1170 | client.close(); 1171 | server.forceShutdown(); 1172 | Assert.strictEqual(err.details, 'bidi-stream-error'); 1173 | barrier.pass(); 1174 | }); 1175 | 1176 | stream.write({}); 1177 | return barrier; 1178 | }); 1179 | }); 1180 | }); 1181 | -------------------------------------------------------------------------------- /test/stream-decoder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Lab = require('@hapi/lab'); 4 | const { StreamDecoder } = require('../lib/stream-decoder'); 5 | const { describe, it } = exports.lab = Lab.script(); 6 | 7 | 8 | describe('StreamDecoder', () => { 9 | describe('write()', () => { 10 | it('throws if the decoder is in an unknown state', () => { 11 | const decoder = new StreamDecoder(); 12 | const data = Buffer.alloc(1); 13 | 14 | decoder.readState = 'invalid'; 15 | Assert.throws(() => { 16 | decoder.write(data); 17 | }, /^Error: Unexpected read state$/); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Assert = require('assert'); 3 | const Lab = require('@hapi/lab'); 4 | const { hasGrpcStatusCode } = require('../lib/utils'); 5 | const Status = require('../lib/status'); 6 | const { describe, it } = exports.lab = Lab.script(); 7 | 8 | 9 | describe('Utils', () => { 10 | describe('hasGrpcStatusCode()', () => { 11 | it('detects valid status codes on objects', () => { 12 | Assert.strictEqual(hasGrpcStatusCode({}), false); 13 | Assert.strictEqual(hasGrpcStatusCode({ code: null }), false); 14 | Assert.strictEqual(hasGrpcStatusCode({ code: -1 }), false); 15 | Assert.strictEqual(hasGrpcStatusCode({ code: 17 }), false); 16 | 17 | Object.keys(Status).forEach((name) => { 18 | const status = Status[name]; 19 | 20 | Assert.strictEqual(hasGrpcStatusCode({ code: status }), true); 21 | 22 | // Make sure no new status codes sneak in. 23 | Assert(status >= 0 && status <= 16); 24 | }); 25 | }); 26 | }); 27 | }); 28 | --------------------------------------------------------------------------------