diff --git a/packages/grpcweb-transport/spec/grpc-web-format.spec.ts b/packages/grpcweb-transport/spec/grpc-web-format.spec.ts index f6b56c2cf..1fdc8673b 100644 --- a/packages/grpcweb-transport/spec/grpc-web-format.spec.ts +++ b/packages/grpcweb-transport/spec/grpc-web-format.spec.ts @@ -202,6 +202,22 @@ describe('readGrpcWebResponse', () => { expect(actual).toEqual([GrpcStatusCode.OK, 'ok', { foo: 'bar, baz, qux'}]); }); + it('does not decode percent-encoded grpc-message by default', function () { + const actual = readGrpcWebResponseHeader({ 'grpc-status': '2', 'grpc-message': 'error%20message' }, 200, ''); + expect(actual).toEqual([GrpcStatusCode.UNKNOWN, 'error%20message', {}]); + }); + + it('decodes percent-encoded grpc-message when decodeMessage is true', function () { + const actual = readGrpcWebResponseHeader({ 'grpc-status': '2', 'grpc-message': 'error%20message%3A%20something%20failed' }, 200, '', true); + expect(actual).toEqual([GrpcStatusCode.UNKNOWN, 'error message: something failed', {}]); + }); + + it('falls back to raw value if grpc-message percent-decoding fails', function () { + // Invalid percent-encoding (% followed by non-hex chars) + const actual = readGrpcWebResponseHeader({ 'grpc-status': '2', 'grpc-message': 'invalid%ZZencoding' }, 200, '', true); + expect(actual).toEqual([GrpcStatusCode.UNKNOWN, 'invalid%ZZencoding', {}]); + }); + it('translates non-OK HTTP responses into gRPC status and message', function () { const statusMap = new Map([ [400, GrpcStatusCode.INVALID_ARGUMENT], @@ -408,6 +424,30 @@ describe('readGrpcWebResponse', () => { expect(trailer).toEqual([GrpcStatusCode.OK, message, {}]) }); + it('does not decode percent-encoded grpc-message by default', function () { + const trailer = readGrpcWebResponseTrailer(trailerFromObject({ + 'grpc-status': GrpcStatusCode.INVALID_ARGUMENT, + 'grpc-message': 'validation%20failed', + })); + expect(trailer).toEqual([GrpcStatusCode.INVALID_ARGUMENT, 'validation%20failed', {}]) + }); + + it('decodes percent-encoded grpc-message when decodeMessage is true', function () { + const trailer = readGrpcWebResponseTrailer(trailerFromObject({ + 'grpc-status': GrpcStatusCode.INVALID_ARGUMENT, + 'grpc-message': 'validation%20failed%3A%20field%20%22name%22%20is%20required', + }), true); + expect(trailer).toEqual([GrpcStatusCode.INVALID_ARGUMENT, 'validation failed: field "name" is required', {}]) + }); + + it('falls back to raw value if grpc-message percent-decoding fails', function () { + const trailer = readGrpcWebResponseTrailer(trailerFromObject({ + 'grpc-status': GrpcStatusCode.INTERNAL, + 'grpc-message': 'bad%GGencoding', + }), true); + expect(trailer).toEqual([GrpcStatusCode.INTERNAL, 'bad%GGencoding', {}]) + }); + it('adds other headers to meta', function () { const someHeader = { foo: 'bar', baz: 'qux' }; const trailer = readGrpcWebResponseTrailer(trailerFromObject(someHeader)); diff --git a/packages/grpcweb-transport/src/grpc-web-format.ts b/packages/grpcweb-transport/src/grpc-web-format.ts index b64779a25..c32bb0d8f 100644 --- a/packages/grpcweb-transport/src/grpc-web-format.ts +++ b/packages/grpcweb-transport/src/grpc-web-format.ts @@ -81,11 +81,13 @@ export function createGrpcWebRequestBody(message: Uint8Array, format: GrpcWebFor * If given a fetch response, checks for fetch-specific error information * ("type" property) and whether the "body" is null and throws a RpcError. */ -export function readGrpcWebResponseHeader(fetchResponse: Response): [GrpcStatusCode | undefined, string | undefined, RpcMetadata]; -export function readGrpcWebResponseHeader(headers: HttpHeaders, httpStatus: number, httpStatusText: string): [GrpcStatusCode | undefined, string | undefined, RpcMetadata]; -export function readGrpcWebResponseHeader(headersOrFetchResponse: HttpHeaders | Response, httpStatus?: number, httpStatusText?: string): [GrpcStatusCode | undefined, string | undefined, RpcMetadata] { - if (arguments.length === 1) { +export function readGrpcWebResponseHeader(fetchResponse: Response, decodeMessage?: boolean): [GrpcStatusCode | undefined, string | undefined, RpcMetadata]; +export function readGrpcWebResponseHeader(headers: HttpHeaders, httpStatus: number, httpStatusText: string, decodeMessage?: boolean): [GrpcStatusCode | undefined, string | undefined, RpcMetadata]; +export function readGrpcWebResponseHeader(headersOrFetchResponse: HttpHeaders | Response, httpStatusOrDecode?: number | boolean, httpStatusText?: string, decodeMessage?: boolean): [GrpcStatusCode | undefined, string | undefined, RpcMetadata] { + // 1-2 args: Response overload; 3-4 args: HttpHeaders overload + if (arguments.length <= 2) { let fetchResponse = headersOrFetchResponse as Response; + let decode = httpStatusOrDecode as boolean | undefined; // Cloudflare Workers throw when the type property of a fetch response // is accessed, so wrap access with try/catch. See: @@ -104,17 +106,19 @@ export function readGrpcWebResponseHeader(headersOrFetchResponse: HttpHeaders | return readGrpcWebResponseHeader( fetchHeadersToHttp(fetchResponse.headers), fetchResponse.status, - fetchResponse.statusText + fetchResponse.statusText, + decode ); } let headers = headersOrFetchResponse as HttpHeaders, - httpOk = httpStatus! >= 200 && httpStatus! < 300, + httpStatus = httpStatusOrDecode as number, + httpOk = httpStatus >= 200 && httpStatus < 300, responseMeta = parseMetadata(headers), - [statusCode, statusDetail] = parseStatus(headers); + [statusCode, statusDetail] = parseStatus(headers, decodeMessage); if ((statusCode === undefined || statusCode === GrpcStatusCode.OK) && !httpOk) { - statusCode = httpStatusToGrpc(httpStatus!); + statusCode = httpStatusToGrpc(httpStatus); statusDetail = httpStatusText; } return [statusCode, statusDetail, responseMeta]; @@ -129,10 +133,10 @@ export function readGrpcWebResponseHeader(headersOrFetchResponse: HttpHeaders | * ASCII string with HTTP headers. Just pass the data of a grpc-web trailer * frame. */ -export function readGrpcWebResponseTrailer(data: Uint8Array): [GrpcStatusCode, string | undefined, RpcMetadata] { +export function readGrpcWebResponseTrailer(data: Uint8Array, decodeMessage?: boolean): [GrpcStatusCode, string | undefined, RpcMetadata] { let headers = parseTrailer(data), - [code, detail] = parseStatus(headers), + [code, detail] = parseStatus(headers, decodeMessage), meta = parseMetadata(headers); return [code ?? GrpcStatusCode.OK, detail, meta]; } @@ -311,14 +315,22 @@ function parseFormat(contentType: string | undefined | null): GrpcWebFormat { // returns error code on parse failure -function parseStatus(headers: HttpHeaders): [GrpcStatusCode | undefined, string | undefined] { +function parseStatus(headers: HttpHeaders, decodeMessage?: boolean): [GrpcStatusCode | undefined, string | undefined] { let code: GrpcStatusCode | undefined, message: string | undefined; let m = headers['grpc-message']; if (m !== undefined) { if (Array.isArray(m)) return [GrpcStatusCode.INTERNAL, "invalid grpc-web message"]; - message = m; + if (decodeMessage) { + try { + message = decodeURIComponent(m); + } catch { + message = m; + } + } else { + message = m; + } } let s = headers['grpc-status']; if (s !== undefined) { diff --git a/packages/grpcweb-transport/src/grpc-web-options.ts b/packages/grpcweb-transport/src/grpc-web-options.ts index c0330e612..cd3d3baef 100644 --- a/packages/grpcweb-transport/src/grpc-web-options.ts +++ b/packages/grpcweb-transport/src/grpc-web-options.ts @@ -37,4 +37,15 @@ export interface GrpcWebOptions extends RpcOptions { * A `fetch` function to use in place of `globalThis.fetch` */ fetch?: typeof fetch; + + /** + * Decode percent-encoded grpc-message header values. + * + * Per the gRPC over HTTP/2 specification, the grpc-message header + * must be percent-encoded. When this option is true, the transport + * will decode the message using decodeURIComponent(). + * + * Defaults to false for backwards compatibility. + */ + decodeGrpcMessage?: boolean; } diff --git a/packages/grpcweb-transport/src/grpc-web-transport.ts b/packages/grpcweb-transport/src/grpc-web-transport.ts index ef7be6240..58e2992c1 100644 --- a/packages/grpcweb-transport/src/grpc-web-transport.ts +++ b/packages/grpcweb-transport/src/grpc-web-transport.ts @@ -91,6 +91,7 @@ export class GrpcWebFetchTransport implements RpcTransport { format = opt.format ?? 'text', fetch = opt.fetch ?? globalThis.fetch, fetchInit = opt.fetchInit ?? {}, + decodeGrpcMessage = opt.decodeGrpcMessage ?? false, url = this.makeUrl(method, opt), inputBytes = method.I.toBinary(input, opt.binaryOptions), defHeader = new Deferred(), @@ -109,7 +110,7 @@ export class GrpcWebFetchTransport implements RpcTransport { signal: options.abort ?? null // node-fetch@3.0.0-beta.9 rejects `undefined` }) .then(fetchResponse => { - let [code, detail, meta] = readGrpcWebResponseHeader(fetchResponse); + let [code, detail, meta] = readGrpcWebResponseHeader(fetchResponse, decodeGrpcMessage); defHeader.resolve(meta); if (code != null && code !== GrpcStatusCode.OK) throw new RpcError(detail ?? GrpcStatusCode[code], GrpcStatusCode[code], meta); @@ -134,7 +135,7 @@ export class GrpcWebFetchTransport implements RpcTransport { break; case GrpcWebFrame.TRAILER: let code, detail; - [code, detail, maybeTrailer] = readGrpcWebResponseTrailer(data); + [code, detail, maybeTrailer] = readGrpcWebResponseTrailer(data, decodeGrpcMessage); maybeStatus = { code: GrpcStatusCode[code], detail: detail ?? GrpcStatusCode[code] @@ -200,6 +201,7 @@ export class GrpcWebFetchTransport implements RpcTransport { format = opt.format ?? 'text', fetch = opt.fetch ?? globalThis.fetch, fetchInit = opt.fetchInit ?? {}, + decodeGrpcMessage = opt.decodeGrpcMessage ?? false, url = this.makeUrl(method, opt), inputBytes = method.I.toBinary(input, opt.binaryOptions), defHeader = new Deferred(), @@ -218,7 +220,7 @@ export class GrpcWebFetchTransport implements RpcTransport { signal: options.abort ?? null // node-fetch@3.0.0-beta.9 rejects `undefined` }) .then(fetchResponse => { - let [code, detail, meta] = readGrpcWebResponseHeader(fetchResponse); + let [code, detail, meta] = readGrpcWebResponseHeader(fetchResponse, decodeGrpcMessage); defHeader.resolve(meta); if (code != null && code !== GrpcStatusCode.OK) throw new RpcError(detail ?? GrpcStatusCode[code], GrpcStatusCode[code], meta); @@ -242,7 +244,7 @@ export class GrpcWebFetchTransport implements RpcTransport { break; case GrpcWebFrame.TRAILER: let code, detail; - [code, detail, maybeTrailer] = readGrpcWebResponseTrailer(data); + [code, detail, maybeTrailer] = readGrpcWebResponseTrailer(data, decodeGrpcMessage); maybeStatus = { code: GrpcStatusCode[code], detail: detail ?? GrpcStatusCode[code]