From 4a9827a8fff3a0b9997cae9e405c4184cb25e0a7 Mon Sep 17 00:00:00 2001 From: Colin Alworth Date: Tue, 10 Dec 2024 15:50:36 -0600 Subject: [PATCH] feat: JS clients can provide custom grpc transports (#6476) Provides a contract for client applications to use a custom http/2 implementation. Roughly abstracted from our TypeScript grpc-web library, with a few rough edges taken off, and no external dependencies. Two integration tests are included, one which requires https (presently only possible with manual testing, see #6421), and one which pretends to contact the server but really responds to every request with a "success" response and no payload. No documentation required at this time, generated typescript includes details on the new APIs. Fixes #6404 --- .../web/client/api/ConnectOptions.java | 24 +-- .../web/client/api/QueryConnectable.java | 17 +-- .../web/client/api/WorkerConnection.java | 2 +- .../client/api/barrage/stream/BiDiStream.java | 14 +- .../web/client/api/grpc/GrpcTransport.java | 74 +++++++++ .../client/api/grpc/GrpcTransportFactory.java | 70 +++++++++ .../client/api/grpc/GrpcTransportOptions.java | 92 +++++++++++ .../web/client/api/grpc/HeaderValueUnion.java | 42 +++++ .../grpc/MultiplexedWebsocketTransport.java | 54 ++++--- .../web/client/ide/IdeConnection.java | 27 ++++ .../web/ClientIntegrationTestSuite.java | 2 + .../client/api/grpc/GrpcTransportTestGwt.java | 143 ++++++++++++++++++ .../transport/TransportOptions.java | 2 +- 13 files changed, 511 insertions(+), 52 deletions(-) create mode 100644 web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransport.java create mode 100644 web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportFactory.java create mode 100644 web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportOptions.java create mode 100644 web/client-api/src/main/java/io/deephaven/web/client/api/grpc/HeaderValueUnion.java create mode 100644 web/client-api/src/test/java/io/deephaven/web/client/api/grpc/GrpcTransportTestGwt.java diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/ConnectOptions.java b/web/client-api/src/main/java/io/deephaven/web/client/api/ConnectOptions.java index 8bf5c5d10bc..16a3711974d 100644 --- a/web/client-api/src/main/java/io/deephaven/web/client/api/ConnectOptions.java +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/ConnectOptions.java @@ -3,7 +3,7 @@ // package io.deephaven.web.client.api; -import elemental2.core.Function; +import io.deephaven.web.client.api.grpc.GrpcTransportFactory; import jsinterop.annotations.JsIgnore; import jsinterop.annotations.JsNullable; import jsinterop.annotations.JsType; @@ -29,20 +29,23 @@ public class ConnectOptions { /** * Set this to true to force the use of websockets when connecting to the deephaven instance, false to force the use - * of {@code fetch}. + * of {@code fetch}. Ignored if {@link #transportFactory} is set. *

* Defaults to null, indicating that the server URL should be checked to see if we connect with fetch or websockets. */ @JsNullable public Boolean useWebsockets; - // TODO (deephaven-core#6214) provide our own grpc-web library that can replace fetch - // /** - // * Optional fetch implementation to use instead of the global {@code fetch()} call, allowing callers to provide a - // * polyfill rather than add a new global. - // */ - // @JsNullable - // public Function fetch; + /** + * The transport factory to use for creating gRPC streams. If specified, the JS API will ignore + * {@link #useWebsockets} and its own internal logic for determining the appropriate transport to use. + *

+ * Defaults to null, indicating that the JS API should determine the appropriate transport to use. If + * {@code useWebsockets} is set to true, the JS API will use websockets, otherwise if the server url begins with + * https, it will use fetch, otherwise it will use websockets. + */ + @JsNullable + public GrpcTransportFactory transportFactory; public ConnectOptions() { @@ -65,5 +68,8 @@ public ConnectOptions(Object connectOptions) { // if (map.has("fetch")) { // fetch = map.getAsAny("fetch").uncheckedCast(); // } + if (map.has("transportFactory")) { + transportFactory = map.getAsAny("transportFactory").uncheckedCast(); + } } } diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/QueryConnectable.java b/web/client-api/src/main/java/io/deephaven/web/client/api/QueryConnectable.java index 1394069e0b7..2a73b357a5e 100644 --- a/web/client-api/src/main/java/io/deephaven/web/client/api/QueryConnectable.java +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/QueryConnectable.java @@ -7,11 +7,9 @@ import elemental2.core.JsArray; import elemental2.core.JsSet; import elemental2.promise.Promise; -import io.deephaven.javascript.proto.dhinternal.grpcweb.Grpc; import io.deephaven.javascript.proto.dhinternal.grpcweb.client.RpcOptions; import io.deephaven.javascript.proto.dhinternal.io.deephaven.proto.session_pb.TerminationNotificationResponse; import io.deephaven.web.client.api.event.HasEventHandling; -import io.deephaven.web.client.api.grpc.MultiplexedWebsocketTransport; import io.deephaven.web.client.ide.IdeSession; import io.deephaven.javascript.proto.dhinternal.io.deephaven.proto.console_pb.*; import io.deephaven.javascript.proto.dhinternal.io.deephaven.proto.ticket_pb.Ticket; @@ -246,12 +244,8 @@ public void disconnected() { public abstract void notifyServerShutdown(TerminationNotificationResponse success); - public boolean useWebsockets() { - Boolean useWebsockets = getOptions().useWebsockets; - if (useWebsockets == null) { - useWebsockets = getServerUrl().startsWith("http:"); - } - return useWebsockets; + public boolean supportsClientStreaming() { + return getOptions().transportFactory.getSupportsClientStreaming(); } public T createClient(BiFunction constructor) { @@ -261,12 +255,7 @@ public T createClient(BiFunction constructor) { public RpcOptions makeRpcOptions() { RpcOptions options = RpcOptions.create(); options.setDebug(getOptions().debug); - if (useWebsockets()) { - // Replace with our custom websocket impl, with fallback to the built-in one - options.setTransport(o -> new MultiplexedWebsocketTransport(o, () -> { - Grpc.setDefaultTransport.onInvoke(Grpc.WebsocketTransport.onInvoke()); - })); - } + options.setTransport(getOptions().transportFactory.adapt()); return options; } } diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/WorkerConnection.java b/web/client-api/src/main/java/io/deephaven/web/client/api/WorkerConnection.java index f8212bb05ac..4ea02498a51 100644 --- a/web/client-api/src/main/java/io/deephaven/web/client/api/WorkerConnection.java +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/WorkerConnection.java @@ -986,7 +986,7 @@ public BrowserHeaders metadata() { } public BiDiStream.Factory streamFactory() { - return new BiDiStream.Factory<>(info.useWebsockets(), this::metadata, config::newTicketInt); + return new BiDiStream.Factory<>(info.supportsClientStreaming(), this::metadata, config::newTicketInt); } public Promise newTable(String[] columnNames, String[] types, Object[][] data, String userTimeZone, diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/barrage/stream/BiDiStream.java b/web/client-api/src/main/java/io/deephaven/web/client/api/barrage/stream/BiDiStream.java index 5febd0a9fa4..4fc65739780 100644 --- a/web/client-api/src/main/java/io/deephaven/web/client/api/barrage/stream/BiDiStream.java +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/barrage/stream/BiDiStream.java @@ -36,12 +36,12 @@ public interface NextStreamMessageFactory { void nextStreamMessage(Req nextPayload, BrowserHeaders headers, JsBiConsumer callback); } public static class Factory { - private final boolean useWebsockets; + private final boolean supportsClientStreaming; private final Supplier headers; private final IntSupplier nextIntTicket; - public Factory(boolean useWebsockets, Supplier headers, IntSupplier nextIntTicket) { - this.useWebsockets = useWebsockets; + public Factory(boolean supportsClientStreaming, Supplier headers, IntSupplier nextIntTicket) { + this.supportsClientStreaming = supportsClientStreaming; this.headers = headers; this.nextIntTicket = nextIntTicket; } @@ -51,8 +51,8 @@ public BiDiStream create( OpenStreamFactory openEmulatedStream, NextStreamMessageFactory nextEmulatedStream, ReqT emptyReq) { - if (useWebsockets) { - return websocket(bidirectionalStream.openBiDiStream(headers.get())); + if (supportsClientStreaming) { + return bidi(bidirectionalStream.openBiDiStream(headers.get())); } else { return new EmulatedBiDiStream<>( openEmulatedStream, @@ -73,7 +73,7 @@ public static BiDiStream of( IntSupplier nextIntTicket, boolean useWebsocket) { if (useWebsocket) { - return websocket(bidirectionalStream.openBiDiStream(headers.get())); + return bidi(bidirectionalStream.openBiDiStream(headers.get())); } else { return new EmulatedBiDiStream<>( openEmulatedStream, @@ -84,7 +84,7 @@ public static BiDiStream of( } } - public static BiDiStream websocket(Object bidirectionalStream) { + public static BiDiStream bidi(Object bidirectionalStream) { return new WebsocketBiDiStream<>(Js.cast(bidirectionalStream)); } diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransport.java b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransport.java new file mode 100644 index 00000000000..d51bfcb33d3 --- /dev/null +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransport.java @@ -0,0 +1,74 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.web.client.api.grpc; + +import com.vertispan.tsdefs.annotations.TsInterface; +import elemental2.core.Uint8Array; +import io.deephaven.javascript.proto.dhinternal.browserheaders.BrowserHeaders; +import io.deephaven.javascript.proto.dhinternal.grpcweb.grpc.Transport; +import jsinterop.annotations.JsIgnore; +import jsinterop.annotations.JsType; +import jsinterop.base.JsPropertyMap; + +/** + * gRPC transport implementation. + * + */ +@JsType(namespace = "dh.grpc") +@TsInterface +public interface GrpcTransport { + /** + * Starts the stream, sending metadata to the server. + * + * @param metadata the headers to send the server when opening the connection + */ + void start(JsPropertyMap metadata); + + /** + * Sends a message to the server. + * + * @param msgBytes bytes to send to the server + */ + void sendMessage(Uint8Array msgBytes); + + /** + * "Half close" the stream, signaling to the server that no more messages will be sent, but that the client is still + * open to receiving messages. + */ + void finishSend(); + + /** + * End the stream, both notifying the server that no more messages will be sent nor received, and preventing the + * client from receiving any more events. + */ + void cancel(); + + /** + * Helper to transform ts implementations to our own api. + */ + @JsIgnore + static GrpcTransport from(Transport tsTransport) { + return new GrpcTransport() { + @Override + public void start(JsPropertyMap metadata) { + tsTransport.start(new BrowserHeaders(metadata)); + } + + @Override + public void sendMessage(Uint8Array msgBytes) { + tsTransport.sendMessage(msgBytes); + } + + @Override + public void finishSend() { + tsTransport.finishSend(); + } + + @Override + public void cancel() { + tsTransport.cancel(); + } + }; + } +} diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportFactory.java b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportFactory.java new file mode 100644 index 00000000000..978e1a36426 --- /dev/null +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportFactory.java @@ -0,0 +1,70 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.web.client.api.grpc; + +import com.vertispan.tsdefs.annotations.TsInterface; +import elemental2.core.Uint8Array; +import io.deephaven.javascript.proto.dhinternal.browserheaders.BrowserHeaders; +import io.deephaven.javascript.proto.dhinternal.grpcweb.transports.transport.Transport; +import io.deephaven.javascript.proto.dhinternal.grpcweb.transports.transport.TransportFactory; +import jsinterop.annotations.JsOverlay; +import jsinterop.annotations.JsProperty; +import jsinterop.annotations.JsType; +import jsinterop.base.Js; + +/** + * Factory for creating gRPC transports. + */ +@TsInterface +@JsType(namespace = "dh.grpc", isNative = true) +public interface GrpcTransportFactory { + /** + * Create a new transport instance. + * + * @param options options for creating the transport + * @return a transport instance to use for gRPC communication + */ + GrpcTransport create(GrpcTransportOptions options); + + /** + * Return true to signal that created transports may have {@link GrpcTransport#sendMessage(Uint8Array)} called on it + * more than once before {@link GrpcTransport#finishSend()} should be called. + * + * @return true to signal that the implementation can stream multiple messages, false otherwise indicating that + * Open/Next gRPC calls should be used + */ + @JsProperty + boolean getSupportsClientStreaming(); + + /** + * Adapt this factory to the transport factory used by the gRPC-web library. + */ + @JsOverlay + default TransportFactory adapt() { + return options -> { + GrpcTransport impl = create(GrpcTransportOptions.from(options)); + return new Transport() { + @Override + public void cancel() { + impl.cancel(); + } + + @Override + public void finishSend() { + impl.finishSend(); + } + + @Override + public void sendMessage(Uint8Array msgBytes) { + impl.sendMessage(msgBytes); + } + + @Override + public void start(BrowserHeaders metadata) { + impl.start(Js.cast(metadata.headersMap)); + } + }; + }; + } +} diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportOptions.java b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportOptions.java new file mode 100644 index 00000000000..8853471a98e --- /dev/null +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/GrpcTransportOptions.java @@ -0,0 +1,92 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.web.client.api.grpc; + +import com.vertispan.tsdefs.annotations.TsInterface; +import elemental2.core.JsError; +import elemental2.core.Uint8Array; +import elemental2.dom.URL; +import io.deephaven.javascript.proto.dhinternal.browserheaders.BrowserHeaders; +import io.deephaven.javascript.proto.dhinternal.grpcweb.transports.transport.TransportOptions; +import jsinterop.annotations.JsFunction; +import jsinterop.annotations.JsIgnore; +import jsinterop.annotations.JsNullable; +import jsinterop.annotations.JsOptional; +import jsinterop.annotations.JsType; +import jsinterop.base.JsPropertyMap; + +/** + * Options for creating a gRPC stream transport instance. + */ +@TsInterface +@JsType(namespace = "dh.grpc") +public class GrpcTransportOptions { + @JsFunction + @FunctionalInterface + public interface OnHeadersCallback { + void onHeaders(JsPropertyMap headers, int status); + } + + @JsFunction + @FunctionalInterface + public interface OnChunkCallback { + void onChunk(Uint8Array chunk); + } + + @JsFunction + @FunctionalInterface + public interface OnEndCallback { + void onEnd(@JsOptional @JsNullable JsError error); + } + + /** + * The gRPC method URL. + */ + public URL url; + + /** + * True to enable debug logging for this stream. + */ + public boolean debug; + + /** + * Callback for when headers and status are received. The headers are a map of header names to values, and the + * status is the HTTP status code. If the connection could not be made, the status should be 0. + */ + public OnHeadersCallback onHeaders; + + /** + * Callback for when a chunk of data is received. + */ + public OnChunkCallback onChunk; + + /** + * Callback for when the stream ends, with an error instance if it can be provided. Note that the present + * implementation does not consume errors, even if provided. + */ + public OnEndCallback onEnd; + + /** + * Internal copy of options, to be used for fallback. + */ + @JsIgnore + public TransportOptions originalOptions; + + /** + * Convert a {@link TransportOptions} instance to a {@link GrpcTransportOptions} instance. + */ + @JsIgnore + public static GrpcTransportOptions from(TransportOptions options) { + GrpcTransportOptions impl = new GrpcTransportOptions(); + impl.url = new URL(options.getUrl()); + impl.debug = options.isDebug(); + impl.onHeaders = (headers, status) -> options.getOnHeaders().onInvoke(new BrowserHeaders(headers), status); + impl.onChunk = p0 -> { + // "false" because the underlying implementation doesn't rely on this anyway. + options.getOnChunk().onInvoke(p0, false); + }; + impl.onEnd = options.getOnEnd()::onInvoke; + return impl; + } +} diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/HeaderValueUnion.java b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/HeaderValueUnion.java new file mode 100644 index 00000000000..9d64a7c0402 --- /dev/null +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/HeaderValueUnion.java @@ -0,0 +1,42 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.web.client.api.grpc; + +import com.vertispan.tsdefs.annotations.TsUnion; +import com.vertispan.tsdefs.annotations.TsUnionMember; +import elemental2.core.JsArray; +import javaemul.internal.annotations.DoNotAutobox; +import jsinterop.annotations.JsOverlay; +import jsinterop.annotations.JsPackage; +import jsinterop.annotations.JsType; +import jsinterop.base.Js; + +/** + * Union of string and array of string, as node/browser APIs tend to accept either for http headers. + */ +@TsUnion +@JsType(name = "?", namespace = JsPackage.GLOBAL, isNative = true) +public interface HeaderValueUnion { + @JsOverlay + static HeaderValueUnion of(@DoNotAutobox Object value) { + return Js.cast(value); + } + + @JsOverlay + default boolean isArray() { + return JsArray.isArray(this); + } + + @TsUnionMember + @JsOverlay + default String asString() { + return Js.cast(this); + } + + @TsUnionMember + @JsOverlay + default JsArray asArray() { + return Js.cast(this); + } +} diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/MultiplexedWebsocketTransport.java b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/MultiplexedWebsocketTransport.java index dfb0fd7cea4..a40cf532e1b 100644 --- a/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/MultiplexedWebsocketTransport.java +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/grpc/MultiplexedWebsocketTransport.java @@ -17,10 +17,10 @@ import io.deephaven.javascript.proto.dhinternal.browserheaders.BrowserHeaders; import io.deephaven.javascript.proto.dhinternal.grpcweb.Grpc; import io.deephaven.javascript.proto.dhinternal.grpcweb.transports.transport.Transport; -import io.deephaven.javascript.proto.dhinternal.grpcweb.transports.transport.TransportOptions; import io.deephaven.web.client.api.JsLazy; import io.deephaven.web.shared.fu.JsRunnable; import jsinterop.base.Js; +import jsinterop.base.JsPropertyMap; import java.util.ArrayList; import java.util.HashMap; @@ -32,11 +32,23 @@ * equal, this transport should be preferred to the default grpc-websockets transport, and in turn the fetch based * transport is usually superior to this. */ -public class MultiplexedWebsocketTransport implements Transport { +public class MultiplexedWebsocketTransport implements GrpcTransport { public static final String MULTIPLEX_PROTOCOL = "grpc-websockets-multiplex"; public static final String SOCKET_PER_STREAM_PROTOCOL = "grpc-websockets"; + public static class Factory implements GrpcTransportFactory { + @Override + public GrpcTransport create(GrpcTransportOptions options) { + return new MultiplexedWebsocketTransport(options); + } + + @Override + public boolean getSupportsClientStreaming() { + return true; + } + } + private static Uint8Array encodeASCII(String str) { Uint8Array encoded = new Uint8Array(str.length()); for (int i = 0; i < str.length(); i++) { @@ -55,9 +67,9 @@ private interface QueuedEntry { public static class HeaderFrame implements QueuedEntry { private final String path; - private final BrowserHeaders metadata; + private final JsPropertyMap metadata; - public HeaderFrame(String path, BrowserHeaders metadata) { + public HeaderFrame(String path, JsPropertyMap metadata) { this.path = path; this.metadata = metadata; } @@ -66,9 +78,14 @@ public HeaderFrame(String path, BrowserHeaders metadata) { public void send(WebSocket webSocket, int streamId) { final Uint8Array headerBytes; final StringBuilder str = new StringBuilder(); - metadata.append("grpc-websockets-path", path); - metadata.forEach((key, value) -> { - str.append(key).append(": ").append(value.join(", ")).append("\r\n"); + metadata.set("grpc-websockets-path", HeaderValueUnion.of(path)); + metadata.forEach((key) -> { + HeaderValueUnion value = metadata.get(key); + if (value.isArray()) { + str.append(key).append(": ").append(value.asArray().join(", ")).append("\r\n"); + } else { + str.append(key).append(": ").append(value.asString()).append("\r\n"); + } }); headerBytes = encodeASCII(str.toString()); Int8Array payload = new Int8Array(headerBytes.byteLength + 4); @@ -79,7 +96,7 @@ public void send(WebSocket webSocket, int streamId) { @Override public void sendFallback(Transport transport) { - transport.start(metadata); + transport.start(new BrowserHeaders(metadata)); } } @@ -201,16 +218,16 @@ private void release() { private ActiveTransport transport; private final int streamId = nextStreamId++; private final List sendQueue = new ArrayList<>(); - private final TransportOptions options; + private final GrpcTransportOptions options; private final String path; private final JsLazy alternativeTransport; private JsRunnable cleanup = JsRunnable.doNothing(); - public MultiplexedWebsocketTransport(TransportOptions options, JsRunnable avoidMultiplexCallback) { + public MultiplexedWebsocketTransport(GrpcTransportOptions options) { this.options = options; - String url = options.getUrl(); + String url = options.url.toString(); URL urlWrapper = new URL(url); // preserve the path to send as metadata, but still talk to the server with that path path = urlWrapper.pathname.substring(1); @@ -220,16 +237,13 @@ public MultiplexedWebsocketTransport(TransportOptions options, JsRunnable avoidM transport = ActiveTransport.get(url); // prepare a fallback - alternativeTransport = new JsLazy<>(() -> { - avoidMultiplexCallback.run(); - return Grpc.WebsocketTransport.onInvoke().onInvoke(options); - }); + alternativeTransport = new JsLazy<>(() -> Grpc.WebsocketTransport.onInvoke().onInvoke(options.originalOptions)); } @Override - public void start(BrowserHeaders metadata) { + public void start(JsPropertyMap metadata) { if (alternativeTransport.isAvailable()) { - alternativeTransport.get().start(metadata); + alternativeTransport.get().start(new BrowserHeaders(metadata)); return; } this.transport.retain(); @@ -325,7 +339,7 @@ private void onClose(Event event) { return; } // each grpc transport will handle this as an error - options.getOnEnd().onInvoke(new JsError("Unexpectedly closed " + Js.uncheckedCast(event).reason)); + options.onEnd.onEnd(new JsError("Unexpectedly closed " + Js.uncheckedCast(event).reason)); removeHandlers(); } @@ -345,9 +359,9 @@ private void onMessage(Event event) { closed = false; } if (streamId == this.streamId) { - options.getOnChunk().onInvoke(new Uint8Array(messageEvent.data, 4), false); + options.onChunk.onChunk(new Uint8Array(messageEvent.data, 4)); if (closed) { - options.getOnEnd().onInvoke(null); + options.onEnd.onEnd(null); removeHandlers(); } } diff --git a/web/client-api/src/main/java/io/deephaven/web/client/ide/IdeConnection.java b/web/client-api/src/main/java/io/deephaven/web/client/ide/IdeConnection.java index bea6aacbba5..1ea9412d769 100644 --- a/web/client-api/src/main/java/io/deephaven/web/client/ide/IdeConnection.java +++ b/web/client-api/src/main/java/io/deephaven/web/client/ide/IdeConnection.java @@ -7,7 +7,10 @@ import elemental2.core.JsArray; import elemental2.promise.Promise; import io.deephaven.javascript.proto.dhinternal.browserheaders.BrowserHeaders; +import io.deephaven.javascript.proto.dhinternal.grpcweb.Grpc; import io.deephaven.javascript.proto.dhinternal.grpcweb.grpc.Code; +import io.deephaven.javascript.proto.dhinternal.grpcweb.grpc.Transport; +import io.deephaven.javascript.proto.dhinternal.grpcweb.transports.transport.TransportOptions; import io.deephaven.javascript.proto.dhinternal.io.deephaven.proto.session_pb.TerminationNotificationResponse; import io.deephaven.javascript.proto.dhinternal.io.deephaven.proto.session_pb.terminationnotificationresponse.StackTrace; import io.deephaven.web.client.api.ConnectOptions; @@ -16,6 +19,10 @@ import io.deephaven.web.client.api.barrage.stream.ResponseStreamWrapper; import io.deephaven.web.client.api.console.JsVariableChanges; import io.deephaven.web.client.api.console.JsVariableDescriptor; +import io.deephaven.web.client.api.grpc.GrpcTransport; +import io.deephaven.web.client.api.grpc.GrpcTransportFactory; +import io.deephaven.web.client.api.grpc.GrpcTransportOptions; +import io.deephaven.web.client.api.grpc.MultiplexedWebsocketTransport; import io.deephaven.web.shared.data.ConnectToken; import io.deephaven.web.shared.fu.JsConsumer; import io.deephaven.web.shared.fu.JsRunnable; @@ -57,6 +64,26 @@ public IdeConnection(String serverUrl, Object connectOptions) { } else { options = new ConnectOptions(); } + if (options.transportFactory == null) { + // assign a default transport factory + if (options.useWebsockets == Boolean.TRUE || !serverUrl.startsWith("https:")) { + options.transportFactory = new MultiplexedWebsocketTransport.Factory(); + } else { + options.transportFactory = new GrpcTransportFactory() { + @Override + public GrpcTransport create(GrpcTransportOptions options) { + return GrpcTransport + .from((Transport) Grpc.FetchReadableStreamTransport.onInvoke(new Object()) + .onInvoke((TransportOptions) options)); + } + + @Override + public boolean getSupportsClientStreaming() { + return false; + } + }; + } + } } @Override diff --git a/web/client-api/src/test/java/io/deephaven/web/ClientIntegrationTestSuite.java b/web/client-api/src/test/java/io/deephaven/web/ClientIntegrationTestSuite.java index 3c1887e8a5c..3c3aa8791ca 100644 --- a/web/client-api/src/test/java/io/deephaven/web/ClientIntegrationTestSuite.java +++ b/web/client-api/src/test/java/io/deephaven/web/ClientIntegrationTestSuite.java @@ -5,6 +5,7 @@ import com.google.gwt.junit.tools.GWTTestSuite; import io.deephaven.web.client.api.*; +import io.deephaven.web.client.api.grpc.GrpcTransportTestGwt; import io.deephaven.web.client.api.storage.JsStorageServiceTestGwt; import io.deephaven.web.client.api.subscription.ConcurrentTableTestGwt; import io.deephaven.web.client.api.subscription.ViewportTestGwt; @@ -30,6 +31,7 @@ public static Test suite() { suite.addTestSuite(JsStorageServiceTestGwt.class); suite.addTestSuite(InputTableTestGwt.class); suite.addTestSuite(ColumnStatisticsTestGwt.class); + suite.addTestSuite(GrpcTransportTestGwt.class); // This should be a unit test, but it requires a browser environment to run on GWT 2.9 // GWT 2.9 doesn't have proper bindings for Promises in HtmlUnit, so we need to use the IntegrationTest suite diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/grpc/GrpcTransportTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/grpc/GrpcTransportTestGwt.java new file mode 100644 index 00000000000..020c65b771a --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/grpc/GrpcTransportTestGwt.java @@ -0,0 +1,143 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.web.client.api.grpc; + +import elemental2.promise.Promise; +import io.deephaven.web.client.api.AbstractAsyncGwtTestCase; +import io.deephaven.web.client.api.ConnectOptions; +import io.deephaven.web.client.api.CoreClient; +import jsinterop.base.JsPropertyMap; + +/** + * Simple test to verify we can produce custom transports in JS. Only works with https, which means it can only be run + * manually at this time, or it will trivially succeed. + */ +public class GrpcTransportTestGwt extends AbstractAsyncGwtTestCase { + @Override + public String getModuleName() { + return "io.deephaven.web.DeephavenIntegrationTest"; + } + + /** + * Simple fetch impl, with no cancelation handling. + */ + public native GrpcTransportFactory makeFetchTransportFactory() /*-{ + return { + create: function(options) { + function pump(reader, res) { + reader.read().then(function(result) { + if (result.done) { + options.onEnd(); + } else { + options.onChunk(result.value); + pump(reader, res); + } + })['catch'](function(e) { + options.onEnd(e); + }); + } + return { + start: function(metadata) { + this.metadata = metadata; + }, + sendMessage: function(msgBytes) { + var fetchInit = { + headers: new Headers(this.metadata), + method: "POST", + body: msgBytes, + }; + $wnd.fetch(options.url.href, fetchInit).then(function(response) { + var m = {}; + response.headers.forEach(function(value, key) { + m[key] = value; + }); + options.onHeaders(m, response.status); + if (response.body) { + pump(response.body.getReader(), response); + } + return response; + })['catch'](function(e) { + options.onEnd(e); + }); + }, + finishSend: function() { + // no-op + }, + cancel: function() { + // no-op + } + }; + }, + supportsClientStreaming: false + }; + }-*/; + + public void testFetchGrpcTransport() { + if (!localServer.startsWith("https:")) { + // We're using h2, so we need to be on https for our current implementation + return; + } + setupDhInternal().then(ignore -> { + delayTestFinish(7101); + ConnectOptions connectOptions = new ConnectOptions(); + connectOptions.transportFactory = makeFetchTransportFactory(); + CoreClient coreClient = new CoreClient(localServer, connectOptions); + return coreClient.login(JsPropertyMap.of("type", CoreClient.LOGIN_TYPE_ANONYMOUS)) + .then(ignore2 -> Promise.resolve(coreClient)); + }).then(this::finish).catch_(this::report); + } + + /** + * Dummy transport that just sends a single message and receives a single message. Doesn't actually talk to the + * server, headers are empty, and the message is always 5 byte proto payload "no data", followed by trailers + * signifying success. + */ + private native GrpcTransportFactory makeDummyTransportFactory() /*-{ + return { + create: function(options) { + return { + start: function(metadata) { + // empty headers + $wnd.setTimeout(function() {options.onHeaders({}, 200);}, 0); + }, + sendMessage: function(msgBytes) { + // empty payload + var empty = new $wnd.Uint8Array(5); + // successful trailer payload + var trailersString = 'grpc-status:0'; + var successTrailers = new $wnd.Uint8Array(5 + trailersString.length); + successTrailers[0] = 128; + successTrailers[4] = trailersString.length; + new $wnd.TextEncoder('utf-8').encodeInto(trailersString, successTrailers.subarray(5)); + $wnd.setTimeout(function() { + // delay a bit, then send the empty messages and end the stream + options.onChunk(empty); + options.onChunk(successTrailers); + options.onEnd(); + }, 0); + }, + finishSend: function() { + // no-op + }, + cancel: function() { + // no-op + } + }; + }, + supportsClientStreaming: true + }; + }-*/; + + public void testDummyGrpcTransport() { + setupDhInternal().then(ignore -> { + delayTestFinish(7102); + ConnectOptions connectOptions = new ConnectOptions(); + connectOptions.transportFactory = makeDummyTransportFactory(); + connectOptions.debug = true; + CoreClient coreClient = new CoreClient(localServer, connectOptions); + return coreClient.login(JsPropertyMap.of("type", CoreClient.LOGIN_TYPE_ANONYMOUS)) + .then(ignore2 -> Promise.resolve(coreClient)); + }).then(this::finish).catch_(this::report); + } +} diff --git a/web/client-backplane/src/main/java/io/deephaven/javascript/proto/dhinternal/grpcweb/transports/transport/TransportOptions.java b/web/client-backplane/src/main/java/io/deephaven/javascript/proto/dhinternal/grpcweb/transports/transport/TransportOptions.java index 7bb39eda843..d9162713094 100644 --- a/web/client-backplane/src/main/java/io/deephaven/javascript/proto/dhinternal/grpcweb/transports/transport/TransportOptions.java +++ b/web/client-backplane/src/main/java/io/deephaven/javascript/proto/dhinternal/grpcweb/transports/transport/TransportOptions.java @@ -33,7 +33,7 @@ public interface OnEndFn { @JsFunction public interface OnHeadersFn { - void onInvoke(BrowserHeaders p0, double p1); + void onInvoke(BrowserHeaders p0, int p1); } @JsOverlay