Skip to content

Commit b73bb36

Browse files
feat(graphql): add support for graphql-transport-ws
1 parent 3f8e0e0 commit b73bb36

File tree

3 files changed

+152
-21
lines changed

3 files changed

+152
-21
lines changed

packages/graphql/lib/src/links/websocket_link/websocket_client.dart

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,17 @@ import 'dart:collection';
33
import 'dart:convert';
44
import 'dart:typed_data';
55

6+
import 'package:gql_exec/gql_exec.dart';
7+
import 'package:graphql/src/core/query_options.dart' show WithType;
68
import 'package:graphql/src/links/gql_links.dart';
79
import 'package:graphql/src/utilities/platform.dart';
810
import 'package:meta/meta.dart';
9-
10-
import 'package:graphql/src/core/query_options.dart' show WithType;
11-
import 'package:gql_exec/gql_exec.dart';
12-
13-
import 'package:stream_channel/stream_channel.dart';
14-
import 'package:web_socket_channel/web_socket_channel.dart';
15-
import 'package:web_socket_channel/status.dart' as ws_status;
16-
1711
import 'package:rxdart/rxdart.dart';
12+
import 'package:stream_channel/stream_channel.dart';
1813
import 'package:uuid/uuid.dart';
1914
import 'package:uuid/uuid_util.dart';
15+
import 'package:web_socket_channel/status.dart' as ws_status;
16+
import 'package:web_socket_channel/web_socket_channel.dart';
2017

2118
import './websocket_messages.dart';
2219

@@ -144,6 +141,13 @@ class SocketClientConfig {
144141
}
145142
}
146143

144+
class SocketSubProtocol {
145+
SocketSubProtocol._();
146+
147+
static const String graphqlWs = "graphql-ws";
148+
static const String graphqlTransportWs = "graphql-transport-ws";
149+
}
150+
147151
/// Wraps a standard web socket instance to marshal and un-marshal the server /
148152
/// client payloads into dart object representation.
149153
///
@@ -155,7 +159,7 @@ class SocketClientConfig {
155159
class SocketClient {
156160
SocketClient(
157161
this.url, {
158-
this.protocols = const ['graphql-ws'],
162+
this.protocol = SocketSubProtocol.graphqlWs,
159163
this.config = const SocketClientConfig(),
160164
@visibleForTesting this.randomBytesForUuid,
161165
@visibleForTesting this.onMessage,
@@ -166,7 +170,7 @@ class SocketClient {
166170

167171
Uint8List? randomBytesForUuid;
168172
final String url;
169-
final Iterable<String>? protocols;
173+
final String protocol;
170174
final SocketClientConfig config;
171175

172176
final BehaviorSubject<SocketConnectionState> _connectionStateController =
@@ -179,6 +183,7 @@ class SocketClient {
179183
bool _wasDisposed = false;
180184

181185
Timer? _reconnectTimer;
186+
Timer? _pingTimer;
182187

183188
@visibleForTesting
184189
GraphQLWebSocketChannel? socketChannel;
@@ -239,17 +244,34 @@ class SocketClient {
239244
// Even though config.connect is sync, we call async in order to make the
240245
// SocketConnectionState.connected attribution not overload SocketConnectionState.connecting
241246
var connection =
242-
await config.connect(uri: Uri.parse(url), protocols: protocols);
247+
await config.connect(uri: Uri.parse(url), protocols: [protocol]);
243248
socketChannel = connection.forGraphQL();
244249
_connectionStateController.add(SocketConnectionState.connected);
245250
_write(initOperation);
246251

247252
if (config.inactivityTimeout != null) {
248-
_disconnectOnKeepAliveTimeout(_messages);
253+
if (protocol == SocketSubProtocol.graphqlWs) {
254+
_disconnectOnKeepAliveTimeout(_messages);
255+
}
256+
if (protocol == SocketSubProtocol.graphqlTransportWs) {
257+
_enqueuePing();
258+
}
249259
}
250260

251261
_messageSubscription = _messages.listen(
252-
onMessage,
262+
(message) {
263+
if (onMessage != null) {
264+
onMessage!(message);
265+
}
266+
267+
if (protocol == SocketSubProtocol.graphqlTransportWs) {
268+
if (message.type == 'ping') {
269+
_write(PongMessage());
270+
} else if (message.type == 'pong') {
271+
_enqueuePing();
272+
}
273+
}
274+
},
253275
onDone: onConnectionLost,
254276
// onDone will not be triggered if the subscription is
255277
// auto-cancelled on error; make sure to pass false
@@ -276,6 +298,7 @@ class SocketClient {
276298
}
277299
print('Disconnected from websocket.');
278300
_reconnectTimer?.cancel();
301+
_pingTimer?.cancel();
279302
_keepAliveSubscription?.cancel();
280303
_messageSubscription?.cancel();
281304

@@ -302,6 +325,14 @@ class SocketClient {
302325
}
303326
}
304327

328+
void _enqueuePing() {
329+
_pingTimer?.cancel();
330+
_pingTimer = new Timer(
331+
config.inactivityTimeout!,
332+
() => _write(PingMessage()),
333+
);
334+
}
335+
305336
/// Closes the underlying socket if connected, and stops reconnection attempts.
306337
/// After calling this method, this [SocketClient] instance must be considered
307338
/// unusable. Instead, create a new instance of this class.
@@ -314,6 +345,7 @@ class SocketClient {
314345
_wasDisposed = true;
315346
print('Disposing socket client..');
316347
_reconnectTimer?.cancel();
348+
_pingTimer?.cancel();
317349
_keepAliveSubscription?.cancel();
318350

319351
await Future.wait([
@@ -385,6 +417,10 @@ class SocketClient {
385417
return message.id == id;
386418
}
387419

420+
if (message is SubscriptionNext) {
421+
return message.id == id;
422+
}
423+
388424
if (message is SubscriptionError) {
389425
return message.id == id;
390426
}
@@ -422,18 +458,34 @@ class SocketClient {
422458
parse(message.toJson()),
423459
));
424460

461+
dataErrorComplete
462+
.where((message) => message is SubscriptionNext)
463+
.cast<SubscriptionNext>()
464+
.listen((message) => response.add(
465+
parse(message.toJson()),
466+
));
467+
425468
dataErrorComplete
426469
.where((message) => message is SubscriptionError)
427470
.cast<SubscriptionError>()
428471
.listen((message) => response.addError(message));
429472

430473
if (!_subscriptionInitializers[id]!.hasBeenTriggered) {
431-
_write(
432-
StartOperation(
433-
id,
434-
serialize(payload),
435-
),
436-
);
474+
if (protocol == SocketSubProtocol.graphqlTransportWs) {
475+
_write(
476+
SubscribeOperation(
477+
id,
478+
serialize(payload),
479+
),
480+
);
481+
} else {
482+
_write(
483+
StartOperation(
484+
id,
485+
serialize(payload),
486+
),
487+
);
488+
}
437489
_subscriptionInitializers[id]!.hasBeenTriggered = true;
438490
}
439491
});
@@ -445,7 +497,8 @@ class SocketClient {
445497
_subscriptionInitializers.remove(id);
446498

447499
sub?.cancel();
448-
if (_connectionStateController.value == SocketConnectionState.connected &&
500+
if (protocol == SocketSubProtocol.graphqlWs &&
501+
_connectionStateController.value == SocketConnectionState.connected &&
449502
socketChannel != null) {
450503
_write(StopOperation(id));
451504
}

packages/graphql/lib/src/links/websocket_link/websocket_link.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import 'package:gql_link/gql_link.dart';
21
import 'package:gql_exec/gql_exec.dart';
2+
import 'package:gql_link/gql_link.dart';
33

44
import './websocket_client.dart';
55

@@ -16,9 +16,11 @@ class WebSocketLink extends Link {
1616
WebSocketLink(
1717
this.url, {
1818
this.config = const SocketClientConfig(),
19+
this.subProtocol = SocketSubProtocol.graphqlWs,
1920
});
2021

2122
final String url;
23+
final String subProtocol;
2224
final SocketClientConfig config;
2325

2426
// cannot be final because we're changing the instance upon a header change.
@@ -39,6 +41,7 @@ class WebSocketLink extends Link {
3941
_socketClient = SocketClient(
4042
url,
4143
config: config,
44+
protocol: subProtocol,
4245
);
4346
}
4447

packages/graphql/lib/src/links/websocket_link/websocket_messages.dart

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@ class MessageTypes {
2020
static const String connectionKeepAlive = "ka";
2121

2222
// client operations
23+
static const String subscribe = "subscribe";
2324
static const String start = "start";
2425
static const String stop = "stop";
2526

27+
static const String ping = "ping";
28+
static const String pong = "pong";
29+
2630
// server operations
2731
static const String data = "data";
32+
static const String next = "next";
2833
static const String error = "error";
2934
static const String complete = "complete";
3035

@@ -71,13 +76,21 @@ abstract class GraphQLSocketMessage extends JsonSerializable {
7176
return ConnectionKeepAlive();
7277

7378
// for completeness
79+
case MessageTypes.subscribe:
80+
return SubscribeOperation(id, payload);
7481
case MessageTypes.start:
7582
return StartOperation(id, payload);
7683
case MessageTypes.stop:
7784
return StopOperation(id);
85+
case MessageTypes.ping:
86+
return PingMessage(payload);
87+
case MessageTypes.pong:
88+
return PongMessage(payload);
7889

7990
case MessageTypes.data:
8091
return SubscriptionData(id, payload['data'], payload['errors']);
92+
case MessageTypes.next:
93+
return SubscriptionNext(id, payload['data'], payload['errors']);
8194
case MessageTypes.error:
8295
return SubscriptionError(id, payload);
8396
case MessageTypes.complete:
@@ -131,6 +144,46 @@ class QueryPayload extends JsonSerializable {
131144
};
132145
}
133146

147+
class SubscribeOperation extends GraphQLSocketMessage {
148+
SubscribeOperation(this.id, this.payload) : super(MessageTypes.subscribe);
149+
150+
final String id;
151+
152+
final Map<String, dynamic> payload;
153+
154+
@override
155+
toJson() => {
156+
"type": type,
157+
"id": id,
158+
"payload": payload,
159+
};
160+
}
161+
162+
class PingMessage extends GraphQLSocketMessage {
163+
PingMessage([this.payload = const <String, dynamic>{}])
164+
: super(MessageTypes.ping);
165+
166+
final Map<String, dynamic> payload;
167+
168+
@override
169+
toJson() => {
170+
"type": type,
171+
"payload": payload,
172+
};
173+
}
174+
175+
class PongMessage extends GraphQLSocketMessage {
176+
PongMessage([this.payload]) : super(MessageTypes.pong);
177+
178+
final Map<String, dynamic>? payload;
179+
180+
@override
181+
toJson() => {
182+
"type": type,
183+
"payload": payload,
184+
};
185+
}
186+
134187
/// A message to tell the server to create a subscription. The contents of the
135188
/// query will be defined by the payload request. The id provided will be used
136189
/// to tag messages such that they can be identified for this subscription
@@ -209,6 +262,28 @@ class SubscriptionData extends GraphQLSocketMessage {
209262
other is SubscriptionData && jsonEncode(other) == jsonEncode(this);
210263
}
211264

265+
class SubscriptionNext extends GraphQLSocketMessage {
266+
SubscriptionNext(this.id, this.data, this.errors) : super(MessageTypes.next);
267+
268+
final String id;
269+
final dynamic data;
270+
final dynamic errors;
271+
272+
@override
273+
toJson() => {
274+
"type": type,
275+
"data": data,
276+
"errors": errors,
277+
};
278+
279+
@override
280+
int get hashCode => toJson().hashCode;
281+
282+
@override
283+
bool operator ==(dynamic other) =>
284+
other is SubscriptionNext && jsonEncode(other) == jsonEncode(this);
285+
}
286+
212287
/// Errors sent from the server to the client if the subscription operation was
213288
/// not successful, usually due to GraphQL validation errors.
214289
class SubscriptionError extends GraphQLSocketMessage {

0 commit comments

Comments
 (0)