diff --git a/CHANGELOG.md b/CHANGELOG.md index 273cc7c5..ad86f534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ the connection, as defined in the gRPC spec. * Upgrade to `package:lints` version 5.0.0 and Dart SDK version 3.5.0. * Upgrade `example/grpc-web` code. +* Update xhr transport to migrate off legacy JS/HTML apis. ## 4.0.1 diff --git a/lib/grpc_or_grpcweb.dart b/lib/grpc_or_grpcweb.dart index b23bed5d..049ceabf 100644 --- a/lib/grpc_or_grpcweb.dart +++ b/lib/grpc_or_grpcweb.dart @@ -14,7 +14,7 @@ // limitations under the License. import 'src/client/grpc_or_grpcweb_channel_grpc.dart' - if (dart.library.html) 'src/client/grpc_or_grpcweb_channel_web.dart'; + if (dart.library.js_interop) 'src/client/grpc_or_grpcweb_channel_web.dart'; import 'src/client/http2_channel.dart'; import 'src/client/options.dart'; diff --git a/lib/src/client/transport/xhr_transport.dart b/lib/src/client/transport/xhr_transport.dart index 6798a779..693088c2 100644 --- a/lib/src/client/transport/xhr_transport.dart +++ b/lib/src/client/transport/xhr_transport.dart @@ -14,11 +14,11 @@ // limitations under the License. import 'dart:async'; -// ignore: deprecated_member_use (#756) -import 'dart:html'; +import 'dart:js_interop'; import 'dart:typed_data'; import 'package:meta/meta.dart'; +import 'package:web/web.dart'; import '../../client/call.dart'; import '../../shared/message.dart'; @@ -31,7 +31,7 @@ import 'web_streams.dart'; const _contentTypeKey = 'Content-Type'; class XhrTransportStream implements GrpcTransportStream { - final HttpRequest _request; + final IXMLHttpRequest _request; final ErrorHandler _onError; final Function(XhrTransportStream stream) _onDone; bool _headersReceived = false; @@ -50,19 +50,20 @@ class XhrTransportStream implements GrpcTransportStream { {required ErrorHandler onError, required onDone}) : _onError = onError, _onDone = onDone { - _outgoingMessages.stream - .map(frame) - .listen((data) => _request.send(data), cancelOnError: true); + _outgoingMessages.stream.map(frame).listen( + (data) => _request.send(Uint8List.fromList(data).toJS), + cancelOnError: true, + onError: _onError); - _request.onReadyStateChange.listen((data) { + _request.onReadyStateChange.listen((_) { if (_incomingProcessor.isClosed) { return; } switch (_request.readyState) { - case HttpRequest.HEADERS_RECEIVED: + case XMLHttpRequest.HEADERS_RECEIVED: _onHeadersReceived(); break; - case HttpRequest.DONE: + case XMLHttpRequest.DONE: _onRequestDone(); _close(); break; @@ -82,13 +83,11 @@ class XhrTransportStream implements GrpcTransportStream { if (_incomingProcessor.isClosed) { return; } - // Use response over responseText as most browsers don't support - // using responseText during an onProgress event. - final responseString = _request.response as String; + final responseText = _request.responseText; final bytes = Uint8List.fromList( - responseString.substring(_requestBytesRead).codeUnits) + responseText.substring(_requestBytesRead).codeUnits) .buffer; - _requestBytesRead = responseString.length; + _requestBytesRead = responseText.length; _incomingProcessor.add(bytes); }); @@ -123,9 +122,11 @@ class XhrTransportStream implements GrpcTransportStream { if (!_headersReceived && !_validateResponseState()) { return; } - if (_request.response == null) { + if (_request.status != 200) { _onError( - GrpcError.unavailable('XhrConnection request null response', null, + GrpcError.unavailable( + 'Request failed with status: ${_request.status}', + null, _request.responseText), StackTrace.current); return; @@ -145,6 +146,110 @@ class XhrTransportStream implements GrpcTransportStream { } } +// XMLHttpRequest is an extension type and can't be extended or implemented. +// This interface is used to allow for mocking XMLHttpRequest in tests of +// XhrClientConnection. +@visibleForTesting +abstract interface class IXMLHttpRequest { + Stream get onReadyStateChange; + Stream get onProgress; + Stream get onError; + int get readyState; + JSAny? get response; + String get responseText; + Map get responseHeaders; + int get status; + + set responseType(String responseType); + set withCredentials(bool withCredentials); + + void abort(); + void open( + String method, + String url, [ + // external default is true + bool async = true, + String? username, + String? password, + ]); + void overrideMimeType(String mimeType); + void send([JSAny? body]); + void setRequestHeader(String header, String value); + + // This method should only be used in production code. + XMLHttpRequest toXMLHttpRequest(); +} + +// IXMLHttpRequest that delegates to a real XMLHttpRequest. +class XMLHttpRequestImpl implements IXMLHttpRequest { + final XMLHttpRequest _xhr = XMLHttpRequest(); + + XMLHttpRequestImpl(); + + @override + Stream get onReadyStateChange => _xhr.onReadyStateChange; + @override + Stream get onProgress => _xhr.onProgress; + @override + Stream get onError => _xhr.onError; + @override + int get readyState => _xhr.readyState; + @override + Map get responseHeaders => _xhr.responseHeaders; + @override + JSAny? get response => _xhr.response; + @override + String get responseText => _xhr.responseText; + @override + int get status => _xhr.status; + + @override + set responseType(String responseType) { + _xhr.responseType = responseType; + } + + @override + set withCredentials(bool withCredentials) { + _xhr.withCredentials = withCredentials; + } + + @override + void abort() { + _xhr.abort(); + } + + @override + void open( + String method, + String url, [ + bool async = true, + String? username, + String? password, + ]) { + _xhr.open(method, url, async, username, password); + } + + @override + void overrideMimeType(String mimeType) { + _xhr.overrideMimeType(mimeType); + } + + @override + void setRequestHeader(String header, String value) { + _xhr.setRequestHeader(header, value); + } + + @override + void send([JSAny? body]) { + _xhr.send(body); + } + + @override + XMLHttpRequest toXMLHttpRequest() { + return _xhr; + } +} + class XhrClientConnection implements ClientConnection { final Uri uri; @@ -154,20 +259,20 @@ class XhrClientConnection implements ClientConnection { @override String get authority => uri.authority; + @override String get scheme => uri.scheme; - void _initializeRequest(HttpRequest request, Map metadata) { - for (final header in metadata.keys) { - request.setRequestHeader(header, metadata[header]!); - } + void _initializeRequest( + IXMLHttpRequest request, Map metadata) { + metadata.forEach(request.setRequestHeader); // Overriding the mimetype allows us to stream and parse the data request.overrideMimeType('text/plain; charset=x-user-defined'); request.responseType = 'text'; } @visibleForTesting - HttpRequest createHttpRequest() => HttpRequest(); + IXMLHttpRequest createHttpRequest() => XMLHttpRequestImpl(); @override GrpcTransportStream makeRequest(String path, Duration? timeout, @@ -195,11 +300,16 @@ class XhrClientConnection implements ClientConnection { _initializeRequest(request, metadata); final transportStream = - XhrTransportStream(request, onError: onError, onDone: _removeStream); + _createXhrTransportStream(request, onError, _removeStream); _requests.add(transportStream); return transportStream; } + XhrTransportStream _createXhrTransportStream(IXMLHttpRequest request, + ErrorHandler onError, void Function(XhrTransportStream stream) onDone) { + return XhrTransportStream(request, onError: onError, onDone: onDone); + } + void _removeStream(XhrTransportStream stream) { _requests.remove(stream); } diff --git a/pubspec.yaml b/pubspec.yaml index b441f101..a89eb3fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: http2: ^2.2.0 protobuf: '>=2.0.0 <4.0.0' clock: ^1.1.1 + web: ^1.1.0 dev_dependencies: build_runner: ^2.0.0 diff --git a/test/client_tests/client_xhr_transport_test.dart b/test/client_tests/client_xhr_transport_test.dart index 30344455..84c805ec 100644 --- a/test/client_tests/client_xhr_transport_test.dart +++ b/test/client_tests/client_xhr_transport_test.dart @@ -17,9 +17,8 @@ library; import 'dart:async'; -// ignore: deprecated_member_use (#756) -import 'dart:html'; - +import 'dart:js_interop'; +import 'dart:typed_data'; import 'package:async/async.dart'; import 'package:grpc/src/client/call.dart'; import 'package:grpc/src/client/transport/xhr_transport.dart'; @@ -28,12 +27,13 @@ import 'package:grpc/src/shared/status.dart'; import 'package:mockito/mockito.dart'; import 'package:stream_transform/stream_transform.dart'; import 'package:test/test.dart'; +import 'package:web/web.dart'; final readyStateChangeEvent = - Event('readystatechange', canBubble: false, cancelable: false); + Event('readystatechange', EventInit(bubbles: false, cancelable: false)); final progressEvent = ProgressEvent('onloadstart'); -class MockHttpRequest extends Mock implements HttpRequest { +class MockHttpRequest extends Mock implements IXMLHttpRequest { MockHttpRequest({int? code}) : status = code ?? 200; // ignore: close_sinks StreamController readyStateChangeController = @@ -54,6 +54,10 @@ class MockHttpRequest extends Mock implements HttpRequest { @override final int status; + @override + String get responseText => + super.noSuchMethod(Invocation.getter(#responseText), returnValue: ''); + @override int get readyState => super.noSuchMethod(Invocation.getter(#readyState), returnValue: -1); @@ -73,7 +77,7 @@ class MockXhrClientConnection extends XhrClientConnection { final int _statusCode; @override - HttpRequest createHttpRequest() { + IXMLHttpRequest createHttpRequest() { final request = MockHttpRequest(code: _statusCode); latestRequest = request; return request; @@ -210,8 +214,8 @@ void main() { await stream.terminate(); final expectedData = frame(data); - expect(verify(connection.latestRequest.send(captureAny)).captured.single, - expectedData); + verify( + connection.latestRequest.send(Uint8List.fromList(expectedData).toJS)); }); test('Stream handles headers properly', () async { @@ -227,14 +231,13 @@ void main() { (error, _) => fail(error.toString())); when(transport.latestRequest.responseHeaders).thenReturn(responseHeaders); - when(transport.latestRequest.response) + when(transport.latestRequest.responseText) .thenReturn(String.fromCharCodes(frame([]))); // Set expectation for request readyState and generate two readyStateChange // events, so that incomingMessages stream completes. - final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; - when(transport.latestRequest.readyState) - .thenAnswer((_) => readyStates.removeAt(0)); + final readyStates = [XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.DONE]; + when(transport.latestRequest.readyState).thenReturnInOrder(readyStates); transport.latestRequest.readyStateChangeController .add(readyStateChangeEvent); transport.latestRequest.readyStateChangeController @@ -269,13 +272,12 @@ void main() { final encodedString = String.fromCharCodes(encodedTrailers); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); - when(connection.latestRequest.response).thenReturn(encodedString); + when(connection.latestRequest.responseText).thenReturn(encodedString); // Set expectation for request readyState and generate events so that // incomingMessages stream completes. - final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; - when(connection.latestRequest.readyState) - .thenAnswer((_) => readyStates.removeAt(0)); + when(connection.latestRequest.readyState).thenReturnInOrder( + [XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.DONE]); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); connection.latestRequest.progressController.add(progressEvent); @@ -305,13 +307,11 @@ void main() { final encodedString = String.fromCharCodes(encoded); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); - when(connection.latestRequest.response).thenReturn(encodedString); - + when(connection.latestRequest.responseText).thenReturn(encodedString); // Set expectation for request readyState and generate events so that // incomingMessages stream completes. - final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; - when(connection.latestRequest.readyState) - .thenAnswer((_) => readyStates.removeAt(0)); + when(connection.latestRequest.readyState).thenReturnInOrder( + [XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.DONE]); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); connection.latestRequest.progressController.add(progressEvent); @@ -339,14 +339,13 @@ void main() { requestHeaders, (error, _) => fail(error.toString())); final data = List.filled(10, 224); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); - when(connection.latestRequest.response) + when(connection.latestRequest.responseText) .thenReturn(String.fromCharCodes(frame(data))); // Set expectation for request readyState and generate events, so that // incomingMessages stream completes. - final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; - when(connection.latestRequest.readyState) - .thenAnswer((_) => readyStates.removeAt(0)); + when(connection.latestRequest.readyState).thenReturnInOrder( + [XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.DONE]); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); connection.latestRequest.progressController.add(progressEvent); @@ -371,7 +370,7 @@ void main() { const errorDetails = 'error details'; when(connection.latestRequest.responseHeaders) .thenReturn({'content-type': 'application/grpc+proto'}); - when(connection.latestRequest.readyState).thenReturn(HttpRequest.DONE); + when(connection.latestRequest.readyState).thenReturn(XMLHttpRequest.DONE); when(connection.latestRequest.responseText).thenReturn(errorDetails); connection.latestRequest.readyStateChangeController .add(readyStateChangeEvent); @@ -400,12 +399,12 @@ void main() { when(connection.latestRequest.responseHeaders).thenReturn(metadata); when(connection.latestRequest.readyState) - .thenReturn(HttpRequest.HEADERS_RECEIVED); + .thenReturn(XMLHttpRequest.HEADERS_RECEIVED); // At first invocation the response should be the the first message, after // that first + last messages. var first = true; - when(connection.latestRequest.response).thenAnswer((_) { + when(connection.latestRequest.responseText).thenAnswer((_) { if (first) { first = false; return encodedStrings[0]; @@ -413,7 +412,7 @@ void main() { return encodedStrings[0] + encodedStrings[1]; }); - final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; + final readyStates = [XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.DONE]; when(connection.latestRequest.readyState) .thenAnswer((_) => readyStates.removeAt(0));