diff --git a/javascript/net/grpc/web/abstractclientbase.js b/javascript/net/grpc/web/abstractclientbase.js index 21da4ac..63157cf 100644 --- a/javascript/net/grpc/web/abstractclientbase.js +++ b/javascript/net/grpc/web/abstractclientbase.js @@ -32,6 +32,20 @@ const MethodDescriptor = goog.require('grpc.web.MethodDescriptor'); const RpcError = goog.require('grpc.web.RpcError'); +/** + * @constructor + * @struct + * @final + */ +const PromiseCallOptions = function() {}; + +/** + * An AbortSignal to abort the call. + * @type {AbortSignal|undefined} + */ +PromiseCallOptions.prototype.signal; + + /** * This interface represents a grpc-web client * @interface @@ -62,10 +76,11 @@ const AbstractClientBase = class { * @param {!Object} metadata User defined call metadata * @param {!MethodDescriptor} * methodDescriptor Information of this RPC method + * @param options Options for the call * @return {!IThenable} * A promise that resolves to the response message */ - thenableCall(method, requestMessage, metadata, methodDescriptor) {} + thenableCall(method, requestMessage, metadata, methodDescriptor, options) {} /** * @abstract @@ -78,21 +93,19 @@ const AbstractClientBase = class { * @return {!ClientReadableStream} The Client Readable Stream */ serverStreaming(method, requestMessage, metadata, methodDescriptor) {} - - /** - * Get the hostname of the current request. - * @static - * @template REQUEST, RESPONSE - * @param {string} method - * @param {!MethodDescriptor} methodDescriptor - * @return {string} - */ - static getHostname(method, methodDescriptor) { - // method = hostname + methodDescriptor.name(relative path of this method) - return method.substr(0, method.length - methodDescriptor.name.length); - } }; +/** + * Get the hostname of the current request. + * @template REQUEST, RESPONSE + * @param {string} method + * @param {!MethodDescriptor} methodDescriptor + * @return {string} + */ +function getHostname(method, methodDescriptor) { + // method = hostname + methodDescriptor.name(relative path of this method) + return method.substr(0, method.length - methodDescriptor.name.length); +} -exports = AbstractClientBase; +exports = {AbstractClientBase, PromiseCallOptions, getHostname}; diff --git a/javascript/net/grpc/web/grpcwebclientbase.js b/javascript/net/grpc/web/grpcwebclientbase.js index 999e4cb..93f2eff 100644 --- a/javascript/net/grpc/web/grpcwebclientbase.js +++ b/javascript/net/grpc/web/grpcwebclientbase.js @@ -28,7 +28,6 @@ goog.module('grpc.web.GrpcWebClientBase'); goog.module.declareLegacyNamespace(); -const AbstractClientBase = goog.require('grpc.web.AbstractClientBase'); const ClientOptions = goog.requireType('grpc.web.ClientOptions'); const ClientReadableStream = goog.require('grpc.web.ClientReadableStream'); const ClientUnaryCallImpl = goog.require('grpc.web.ClientUnaryCallImpl'); @@ -40,6 +39,7 @@ const RpcError = goog.require('grpc.web.RpcError'); const StatusCode = goog.require('grpc.web.StatusCode'); const XhrIo = goog.require('goog.net.XhrIo'); const googCrypt = goog.require('goog.crypt.base64'); +const {AbstractClientBase, PromiseCallOptions, getHostname} = goog.require('grpc.web.AbstractClientBase'); const {Status} = goog.require('grpc.web.Status'); const {StreamInterceptor, UnaryInterceptor} = goog.require('grpc.web.Interceptor'); const {toObject} = goog.require('goog.collections.maps'); @@ -101,7 +101,7 @@ class GrpcWebClientBase { * @export */ rpcCall(method, requestMessage, metadata, methodDescriptor, callback) { - const hostname = AbstractClientBase.getHostname(method, methodDescriptor); + const hostname = getHostname(method, methodDescriptor); const invoker = GrpcWebClientBase.runInterceptors_( (request) => this.startStream_(request, hostname), this.streamInterceptors_); @@ -112,18 +112,35 @@ class GrpcWebClientBase { } /** - * @override - * @export + * @param {string} method The method to invoke + * @param {REQUEST} requestMessage The request proto + * @param {!Object} metadata User defined call metadata + * @param {!MethodDescriptor} methodDescriptor + * @param {?PromiseCallOptions=} options Options for the call + * @return {!Promise} + * @template REQUEST, RESPONSE */ - thenableCall(method, requestMessage, metadata, methodDescriptor) { - const hostname = AbstractClientBase.getHostname(method, methodDescriptor); + thenableCall( + method, requestMessage, metadata, methodDescriptor, options = {}) { + const hostname = getHostname(method, methodDescriptor); + const signal = options && options.signal; const initialInvoker = (request) => new Promise((resolve, reject) => { + // If the signal is already aborted, immediately reject the promise + // and don't issue the call. + if (signal && signal.aborted) { + const error = new RpcError(StatusCode.CANCELLED, 'Aborted'); + error.cause = signal.reason; + reject(error); + return; + } + const stream = this.startStream_(request, hostname); let unaryMetadata; let unaryStatus; let unaryMsg; GrpcWebClientBase.setCallback_( - stream, (error, response, status, metadata, unaryResponseReceived) => { + stream, + (error, response, status, metadata, unaryResponseReceived) => { if (error) { reject(error); } else if (unaryResponseReceived) { @@ -136,7 +153,19 @@ class GrpcWebClientBase { resolve(request.getMethodDescriptor().createUnaryResponse( unaryMsg, unaryMetadata, unaryStatus)); } - }, true); + }, + true); + + // Wire up cancellation from the abort signal, if any. + if (signal) { + signal.addEventListener('abort', () => { + stream.cancel(); + + const error = new RpcError(StatusCode.CANCELLED, 'Aborted'); + error.cause = /** @type {!AbortSignal} */ (signal).reason; + reject(error); + }); + } }); const invoker = GrpcWebClientBase.runInterceptors_( initialInvoker, this.unaryInterceptors_); @@ -152,12 +181,13 @@ class GrpcWebClientBase { * @param {!Object} metadata User defined call metadata * @param {!MethodDescriptor} methodDescriptor Information * of this RPC method + * @param {?PromiseCallOptions=} options Options for the call * @return {!Promise} * @template REQUEST, RESPONSE */ - unaryCall(method, requestMessage, metadata, methodDescriptor) { - return /** @type {!Promise}*/ ( - this.thenableCall(method, requestMessage, metadata, methodDescriptor)); + unaryCall(method, requestMessage, metadata, methodDescriptor, options = {}) { + return /** @type {!Promise}*/ (this.thenableCall( + method, requestMessage, metadata, methodDescriptor, options)); } /** @@ -165,7 +195,7 @@ class GrpcWebClientBase { * @export */ serverStreaming(method, requestMessage, metadata, methodDescriptor) { - const hostname = AbstractClientBase.getHostname(method, methodDescriptor); + const hostname = getHostname(method, methodDescriptor); const invoker = GrpcWebClientBase.runInterceptors_( (request) => this.startStream_(request, hostname), this.streamInterceptors_); @@ -279,7 +309,9 @@ class GrpcWebClientBase { message: 'Incomplete response', }); } else if (useUnaryResponse) { - callback(null, responseReceived, null, null, /* unaryResponseReceived= */ true); + callback( + null, responseReceived, null, null, + /* unaryResponseReceived= */ true); } else { callback(null, responseReceived); } @@ -368,12 +400,9 @@ class GrpcWebClientBase { * (!Promise|!ClientReadableStream)} */ static runInterceptors_(invoker, interceptors) { - let curInvoker = invoker; - interceptors.forEach((interceptor) => { - const lastInvoker = curInvoker; - curInvoker = (request) => interceptor.intercept(request, lastInvoker); - }); - return curInvoker; + return interceptors.reduce((accumulatedInvoker, interceptor) => { + return (request) => interceptor.intercept(request, accumulatedInvoker); + }, invoker); } } diff --git a/javascript/net/grpc/web/grpcwebclientbase_test.js b/javascript/net/grpc/web/grpcwebclientbase_test.js index e361440..2a859bb 100644 --- a/javascript/net/grpc/web/grpcwebclientbase_test.js +++ b/javascript/net/grpc/web/grpcwebclientbase_test.js @@ -19,6 +19,7 @@ goog.module('grpc.web.GrpcWebClientBaseTest'); goog.setTestOnly('grpc.web.GrpcWebClientBaseTest'); const ClientReadableStream = goog.require('grpc.web.ClientReadableStream'); +const ErrorCode = goog.require('goog.net.ErrorCode'); const GrpcWebClientBase = goog.require('grpc.web.GrpcWebClientBase'); const MethodDescriptor = goog.require('grpc.web.MethodDescriptor'); const ReadyState = goog.require('goog.net.XmlHttp.ReadyState'); @@ -146,6 +147,58 @@ testSuite({ assertElementsEquals(DEFAULT_UNARY_HEADER_VALUES, Object.values(headers)); }, + + async testCancelledThenableCall() { + const xhr = new XhrIo(); + const client = new GrpcWebClientBase(/* options= */ {}, xhr); + const methodDescriptor = createMethodDescriptor((bytes) => { + assertElementsEquals(DEFAULT_RPC_RESPONSE_DATA, [].slice.call(bytes)); + return 0; + }); + + const abortController = new AbortController(); + const signal = abortController.signal; + const responsePromise = client.thenableCall( + 'url', new MockRequest(), /* metadata= */ {}, methodDescriptor, + {signal}); + abortController.abort(); + + const error = await assertRejects(responsePromise); + assertTrue(error instanceof RpcError); + assertEquals(StatusCode.CANCELLED, /** @type {!RpcError} */ (error).code); + assertEquals('Aborted', /** @type {!RpcError} */ (error).message); + // Default abort reason if none provided. + const cause = /** @type {!RpcError} */ (error).cause; + assertTrue(cause instanceof Error); + assertEquals('AbortError', /** @type {!Error} */ (cause).name); + assertEquals(ErrorCode.ABORT, xhr.getLastErrorCode()); + }, + + async testCancelledThenableCallWithReason() { + const xhr = new XhrIo(); + const client = new GrpcWebClientBase(/* options= */ {}, xhr); + const methodDescriptor = createMethodDescriptor((bytes) => { + assertElementsEquals(DEFAULT_RPC_RESPONSE_DATA, [].slice.call(bytes)); + return 0; + }); + + const abortController = new AbortController(); + const signal = abortController.signal; + const responsePromise = client.thenableCall( + 'url', new MockRequest(), /* metadata= */ {}, methodDescriptor, + {signal}); + abortController.abort('cancelling'); + + const error = await assertRejects(responsePromise); + assertTrue(error instanceof RpcError); + assertEquals(StatusCode.CANCELLED, /** @type {!RpcError} */ (error).code); + assertEquals('Aborted', /** @type {!RpcError} */ (error).message); + // Abort reason forwarded as cause. + const cause = /** @type {!RpcError} */ (error).cause; + assertEquals('cancelling', cause); + assertEquals(ErrorCode.ABORT, xhr.getLastErrorCode()); + }, + async testDeadline() { const xhr = new XhrIo(); const client = new GrpcWebClientBase(/* options= */ {}, xhr); diff --git a/packages/grpc-web/docker/jsunit-test/Dockerfile b/packages/grpc-web/docker/jsunit-test/Dockerfile index 29519f0..909da30 100644 --- a/packages/grpc-web/docker/jsunit-test/Dockerfile +++ b/packages/grpc-web/docker/jsunit-test/Dockerfile @@ -1,4 +1,4 @@ -FROM selenium/standalone-chrome:93.0.4577.63 +FROM selenium/standalone-chrome:112.0.5615.165 # Matching the node version used in the node:20.0.0-bullseye image. ARG NODE_VERSION=20.0.0 diff --git a/packages/grpc-web/index.d.ts b/packages/grpc-web/index.d.ts index 09fb671..fc01a07 100644 --- a/packages/grpc-web/index.d.ts +++ b/packages/grpc-web/index.d.ts @@ -7,7 +7,8 @@ declare module "grpc-web" { method: string, request: REQ, metadata: Metadata, - methodDescriptor: MethodDescriptor + methodDescriptor: MethodDescriptor, + options?: PromiseCallOptions ): Promise; rpcCall ( @@ -64,8 +65,10 @@ declare module "grpc-web" { Promise>): Promise>; } - export class CallOptions { - constructor(options: { [index: string]: any; }); + /** Options for gRPC-Web calls returning a Promise. */ + export interface PromiseCallOptions { + /** An AbortSignal to abort the call. */ + readonly signal?: AbortSignal; } export class MethodDescriptor { @@ -82,7 +85,6 @@ declare module "grpc-web" { getRequestMessage(): REQ; getMethodDescriptor(): MethodDescriptor; getMetadata(): Metadata; - getCallOptions(): CallOptions; } export class UnaryResponse { diff --git a/packages/grpc-web/scripts/run_jsunit_tests.sh b/packages/grpc-web/scripts/run_jsunit_tests.sh index a3a5a05..95ed115 100755 --- a/packages/grpc-web/scripts/run_jsunit_tests.sh +++ b/packages/grpc-web/scripts/run_jsunit_tests.sh @@ -44,8 +44,8 @@ trap cleanup EXIT echo "Using Headless Chrome." # Updates Selenium Webdriver. -echo "$PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=93.0.4577.63 --gecko=false" -$PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=93.0.4577.63 --gecko=false +echo "$PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=112.0.5615.165 --gecko=false" +$PROTRACTOR_BIN_PATH/webdriver-manager update --versions.chrome=112.0.5615.165 --gecko=false # Run the tests using Protractor! (Protractor should run selenium automatically) $PROTRACTOR_BIN_PATH/protractor protractor.conf.js