From 8064b4a1802e8fcf06e67640effd7ae40d6ce13e Mon Sep 17 00:00:00 2001 From: melloware Date: Sat, 11 Mar 2023 09:33:55 -0500 Subject: [PATCH] Fix #268: HMR support for web sockets --- .../quinoa/deployment/DevServerConfig.java | 14 +++ .../deployment/ForwardedDevProcessor.java | 25 ++++- .../packagemanager/PackageManager.java | 2 +- .../ROOT/pages/includes/quarkus-quinoa.adoc | 32 ++++++ docs/modules/ROOT/pages/index.adoc | 40 ++++--- .../src/main/resources/application.properties | 2 +- .../src/main/ui-angular/package.json | 2 +- .../quinoa/QuinoaDevProxyHandler.java | 57 +++++++--- .../QuinoaDevWebSocketProxyHandler.java | 101 ++++++++++++++++++ .../io/quarkiverse/quinoa/QuinoaRecorder.java | 4 +- 10 files changed, 237 insertions(+), 42 deletions(-) create mode 100644 runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevWebSocketProxyHandler.java diff --git a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/DevServerConfig.java b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/DevServerConfig.java index ad422951..0de727f0 100644 --- a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/DevServerConfig.java +++ b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/DevServerConfig.java @@ -18,6 +18,13 @@ public class DevServerConfig { @ConfigItem(name = ConfigItem.PARENT, defaultValue = "true") public boolean enabled; + /** + * When set to true, Quinoa will manage the Web UI dev server + * When set to false, the Web UI dev server have to be started before running Quarkus dev + */ + @ConfigItem(defaultValue = "true") + public boolean managed; + /** * Port of the server to forward requests to. * The dev server process (i.e npm start) is managed like a dev service by Quarkus. @@ -36,6 +43,13 @@ public class DevServerConfig { @ConfigItem(defaultValue = "/") public Optional checkPath; + /** + * By default, Quinoa will handle request upgrade to websocket and act as proxy with the dev server. + * If set to false, Quinoa will pass websocket upgrade request to the next Vert.x route handler. + */ + @ConfigItem(defaultValue = "true") + public boolean websocket; + /** * Timeout in ms for the dev server to be up and running. * If not set the default is ~30000ms. diff --git a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java index df4d6c7b..5119f0b1 100644 --- a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java +++ b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/ForwardedDevProcessor.java @@ -44,6 +44,7 @@ import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; +import io.quarkus.vertx.http.deployment.WebsocketSubProtocolsBuildItem; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; public class ForwardedDevProcessor { @@ -103,22 +104,31 @@ public void run() { return null; } + PackageManager packageManager = quinoaDir.get().getPackageManager(); + final int devServerPort = quinoaConfig.devServer.port.getAsInt(); + final String checkPath = quinoaConfig.devServer.checkPath.orElse(null); + if (!quinoaConfig.devServer.managed) { + if (PackageManager.isDevServerUp(checkPath, devServerPort)) { + return new ForwardedDevServerBuildItem(devServerPort); + } else { + throw new IllegalStateException( + "The Web UI dev server (configured as not managed by Quinoa) is not started on port: " + devServerPort); + } + } + StartupLogCompressor compressor = new StartupLogCompressor( (launchMode.isTest() ? "(test) " : "") + "Quinoa package manager live coding dev service starting:", consoleInstalled, loggingSetup, PROCESS_THREAD_PREDICATE); - - PackageManager packageManager = quinoaDir.get().getPackageManager(); final AtomicReference dev = new AtomicReference<>(); try { - final int devServerPort = quinoaConfig.devServer.port.getAsInt(); final int checkTimeout = quinoaConfig.devServer.checkTimeout; if (checkTimeout < 1000) { throw new ConfigurationException("quarkus.quinoa.dev-server.check-timeout must be greater than 1000ms"); } final long start = Instant.now().toEpochMilli(); - final String checkPath = quinoaConfig.devServer.checkPath.orElse(null); + dev.set(packageManager.dev(devServerPort, checkPath, checkTimeout)); compressor.close(); final LiveCodingLogOutputFilter logOutputFilter = new LiveCodingLogOutputFilter( @@ -154,6 +164,7 @@ public void runtimeInit( Optional devProxy, CoreVertxBuildItem vertx, BuildProducer routes, + BuildProducer websocketSubProtocols, BuildProducer resumeOn404) throws IOException { if (quinoaConfig.justBuild) { LOG.info("Quinoa is in build only mode"); @@ -163,8 +174,12 @@ public void runtimeInit( LOG.infof("Quinoa is forwarding unhandled requests to port: %d", quinoaConfig.devServer.port.getAsInt()); final QuinoaHandlerConfig handlerConfig = quinoaConfig.toHandlerConfig(false, httpBuildTimeConfig); routes.produce(RouteBuildItem.builder().orderedRoute("/*", QUINOA_ROUTE_ORDER) - .handler(recorder.quinoaProxyDevHandler(handlerConfig, vertx.getVertx(), devProxy.get().getPort())) + .handler(recorder.quinoaProxyDevHandler(handlerConfig, vertx.getVertx(), devProxy.get().getPort(), + quinoaConfig.devServer.websocket)) .build()); + if (quinoaConfig.devServer.websocket) { + websocketSubProtocols.produce(new WebsocketSubProtocolsBuildItem("*")); + } if (quinoaConfig.enableSPARouting) { resumeOn404.produce(new ResumeOn404BuildItem()); routes.produce(RouteBuildItem.builder().orderedRoute("/*", QUINOA_SPA_ROUTE_ORDER) diff --git a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/packagemanager/PackageManager.java b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/packagemanager/PackageManager.java index 521c4d58..bbddcb8f 100644 --- a/deployment/src/main/java/io/quarkiverse/quinoa/deployment/packagemanager/PackageManager.java +++ b/deployment/src/main/java/io/quarkiverse/quinoa/deployment/packagemanager/PackageManager.java @@ -243,7 +243,7 @@ public void run() { } } - private static boolean isDevServerUp(String path, int port) { + public static boolean isDevServerUp(String path, int port) { try { final String normalizedPath = path.indexOf("/") == 0 ? path : "/" + path; URL url = new URL("http://localhost:" + port + normalizedPath); diff --git a/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc b/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc index 81ed7ed9..f3d96a2c 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-quinoa.adoc @@ -378,6 +378,22 @@ endif::add-copy-button-to-env-var[] |`true` +a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus.quinoa.dev-server.managed]]`link:#quarkus-quinoa_quarkus.quinoa.dev-server.managed[quarkus.quinoa.dev-server.managed]` + +[.description] +-- +When set to true, Quinoa will manage the Web UI dev server When set to false, the Web UI dev server have to be started before running Quarkus dev + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_QUINOA_DEV_SERVER_MANAGED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_QUINOA_DEV_SERVER_MANAGED+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`true` + + a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus.quinoa.dev-server.port]]`link:#quarkus-quinoa_quarkus.quinoa.dev-server.port[quarkus.quinoa.dev-server.port]` [.description] @@ -410,6 +426,22 @@ endif::add-copy-button-to-env-var[] |`/` +a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus.quinoa.dev-server.websocket]]`link:#quarkus-quinoa_quarkus.quinoa.dev-server.websocket[quarkus.quinoa.dev-server.websocket]` + +[.description] +-- +By default, Quinoa will handle request upgrade to websocket and act as proxy with the dev server. If set to false, Quinoa will pass websocket upgrade request to the next Vert.x route handler. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_QUINOA_DEV_SERVER_WEBSOCKET+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_QUINOA_DEV_SERVER_WEBSOCKET+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`true` + + a|icon:lock[title=Fixed at build time] [[quarkus-quinoa_quarkus.quinoa.dev-server.check-timeout]]`link:#quarkus-quinoa_quarkus.quinoa.dev-server.check-timeout[quarkus.quinoa.dev-server.check-timeout]` [.description] diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index f108a5e1..77095128 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -258,7 +258,7 @@ App created by `ng` (https://angular.io/guide/setup-local) require a tiny bit of quarkus.quinoa.build-dir=dist/[your-app-name] ---- -To enable Angular live coding server, you need to edit the package.json start script with `ng serve --host 0.0.0.0 --no-live-reload`, then add this configuration: +To enable Angular live coding server, you need to edit the package.json start script with `ng serve --host 0.0.0.0 --disable-host-check`, then add this configuration: [source,properties] ---- quarkus.quinoa.dev-server.port=4200 @@ -287,6 +287,29 @@ Edit the karma.conf.js: }, ---- +[#nextjs] +=== Next.js +Any app created with Next.js (https://nextjs.org/) should work with Quinoa after the following changes: + +In application.properties add: +[source,properties] +---- +%dev.quarkus.quinoa.index-page=/ +quarkus.quinoa.build-dir=out +---- + +In Dev mode Next.js serves everything out of root "/" but in PRD mode its the normal "/index.html". + +Add these scripts to package.json +[source,json] +---- + "scripts": { + ... + "start": "next dev", + "build": "next build && next export", + } +---- + [#vite] === Vite Any app created with Vite (https://vitejs.dev/guide/) should work with Quinoa after the following changes: @@ -307,20 +330,7 @@ Add start script to package.json }, ---- -To make hot module replacement work, add the following to the config object in vite.config.js: -[source,javascript] ----- - server: { - port: 5173, - host: '127.0.0.1', - hmr: { - port: 5173, - host: '127.0.0.1', - } - } ----- - -You do need the host and port in both places to ensure the websocket is constructed and upgraded properly. +Hot Module Replacement (HMR) should work by default. [#spa-routing] === Single Page application routing diff --git a/integration-tests/src/main/resources/application.properties b/integration-tests/src/main/resources/application.properties index d21cf7a1..333f09d6 100644 --- a/integration-tests/src/main/resources/application.properties +++ b/integration-tests/src/main/resources/application.properties @@ -30,7 +30,7 @@ %react-just-build.quarkus.quinoa.just-build=true -#%angular-dev.quarkus.quinoa.dev-server.port=4200 +%angular-dev.quarkus.quinoa.dev-server.port=4200 %angular-dev.quarkus.quinoa.ui-dir=src/main/ui-angular %angular-dev.quarkus.quinoa.build-dir=dist/quinoa-app %angular-dev.quarkus.quinoa.enable-spa-routing=true diff --git a/integration-tests/src/main/ui-angular/package.json b/integration-tests/src/main/ui-angular/package.json index 3135a85a..7c0c4926 100644 --- a/integration-tests/src/main/ui-angular/package.json +++ b/integration-tests/src/main/ui-angular/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve --host 0.0.0.0 --no-live-reload", + "start": "ng serve --host 0.0.0.0 --disable-host-check", "build": "npm run something && ng build", "something": "echo \"something\"", "watch": "ng build --watch --configuration development", diff --git a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevProxyHandler.java b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevProxyHandler.java index 33821709..3a4c2766 100644 --- a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevProxyHandler.java +++ b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevProxyHandler.java @@ -31,12 +31,14 @@ class QuinoaDevProxyHandler implements Handler { HttpHeaders.CONTENT_TYPE.toString()); private final int port; private final WebClient client; + private final QuinoaDevWebSocketProxyHandler wsUpgradeHandler; private final ClassLoader currentClassLoader; private final QuinoaHandlerConfig config; - QuinoaDevProxyHandler(final QuinoaHandlerConfig config, final Vertx vertx, int port) { + QuinoaDevProxyHandler(final QuinoaHandlerConfig config, final Vertx vertx, int port, boolean websocket) { this.port = port; this.client = WebClient.create(vertx); + this.wsUpgradeHandler = websocket ? new QuinoaDevWebSocketProxyHandler(vertx, port) : null; this.config = config; currentClassLoader = Thread.currentThread().getContextClassLoader(); } @@ -52,14 +54,34 @@ public void handle(final RoutingContext ctx) { next(currentClassLoader, ctx); return; } - final HttpServerRequest request = ctx.request(); + final String resourcePath = path.endsWith("/") ? path + config.indexPage : path; if (isIgnored(resourcePath, config.ignoredPathPrefixes)) { next(currentClassLoader, ctx); return; } - final String uri = computeURI(resourcePath, request); + + if (isUpgradeToWebSocket(ctx)) { + if (this.wsUpgradeHandler != null) { + wsUpgradeHandler.handle(ctx); + } else { + next(currentClassLoader, ctx); + } + } else { + handleHttpRequest(ctx, resourcePath); + } + } + + private static boolean isUpgradeToWebSocket(RoutingContext ctx) { + return ctx.request().headers().contains("Upgrade") + && "websocket".equalsIgnoreCase(ctx.request().headers().get("Upgrade")); + } + + private void handleHttpRequest(final RoutingContext ctx, final String resourcePath) { + final HttpServerRequest request = ctx.request(); final MultiMap headers = request.headers(); + final String uri = computeResourceURI(resourcePath, request); + // Workaround for issue https://github.com/quarkiverse/quarkus-quinoa/issues/91 // See https://www.npmjs.com/package/connect-history-api-fallback#htmlacceptheaders // When no Accept header is provided, the historyApiFallback is disabled @@ -68,26 +90,26 @@ public void handle(final RoutingContext ctx) { headers.remove("Accept-Encoding"); client.request(request.method(), port, request.localAddress().host(), uri) .putHeaders(headers) - .send(new Handler>>() { - @Override - public void handle(AsyncResult> event) { - if (event.succeeded()) { - final int statusCode = event.result().statusCode(); - if (statusCode == 200) { + .send(event -> { + if (event.succeeded()) { + final int statusCode = event.result().statusCode(); + switch (statusCode) { + case 200: forwardResponse(event, request, ctx, resourcePath); - } else if (statusCode == 404) { + break; + case 404: next(currentClassLoader, ctx); - } else { + break; + default: forwardError(event, statusCode, ctx); - } - } else { - error(event, ctx); } + } else { + error(event, ctx); } }); } - private String computeURI(String path, HttpServerRequest request) { + private String computeResourceURI(String path, HttpServerRequest request) { String uri = path; final String query = request.query(); if (query != null) { @@ -126,8 +148,9 @@ private void forwardResponse(AsyncResult> event, HttpServer } private void error(AsyncResult> event, RoutingContext ctx) { + final String error = String.format("Quinoa failed to forward request '%s', see logs.", ctx.request().uri()); ctx.response().setStatusCode(500); - ctx.response().send("Quinoa failed to forward request, see logs."); - LOG.error("Quinoa failed to forward request, see logs.", event.cause()); + ctx.response().send(error); + LOG.error(error, event.cause()); } } diff --git a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevWebSocketProxyHandler.java b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevWebSocketProxyHandler.java new file mode 100644 index 00000000..c9304da0 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaDevWebSocketProxyHandler.java @@ -0,0 +1,101 @@ +package io.quarkiverse.quinoa; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.jboss.logging.Logger; + +import io.quarkus.runtime.util.StringUtil; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.ServerWebSocket; +import io.vertx.core.http.WebSocket; +import io.vertx.core.http.WebSocketConnectOptions; +import io.vertx.ext.web.RoutingContext; + +class QuinoaDevWebSocketProxyHandler { + private static final Logger LOG = Logger.getLogger(QuinoaDevWebSocketProxyHandler.class); + private final HttpClient httpClient; + private final int port; + + QuinoaDevWebSocketProxyHandler(Vertx vertx, int port) { + this.httpClient = vertx.createHttpClient(); + this.port = port; + } + + public void handle(final RoutingContext ctx) { + final HttpServerRequest request = ctx.request(); + ctx.request().pause(); + request.toWebSocket(r -> { + if (r.succeeded()) { + final String host = request.localAddress().host(); + final String forwardUri = request.uri(); + LOG.debugf("Quinoa Dev WebSocket Server Connected: %s:%s%s", host, port, forwardUri); + final ServerWebSocket serverWs = r.result(); + final AtomicReference clientWs = new AtomicReference<>(); + serverWs + .exceptionHandler( + (e) -> LOG.errorf(e, "Quinoa Dev WebSocket Server closed with error: %s", e.getMessage())) + .closeHandler((__) -> { + clientWs.getAndUpdate(w -> { + if (w != null && !w.isClosed()) { + w.close(); + } + return null; + }); + LOG.debug("Quinoa Dev WebSocket Server is closed"); + }); + + // some servers use sub-protocols like Vite which must be forwarded + final String subProtocol = serverWs.subProtocol(); + final List subProtocols = new ArrayList<>(1); + if (!StringUtil.isNullOrEmpty(subProtocol)) { + subProtocols.add(subProtocol); + LOG.debugf("Quinoa Dev WebSocket SubProtocol: %s", subProtocol); + } + + final WebSocketConnectOptions options = new WebSocketConnectOptions() + .setHost(host) + .setPort(port) + .setURI(forwardUri) + .setHeaders(serverWs.headers()) + .setSubProtocols(subProtocols) + .setAllowOriginHeader(false); + serverWs.accept(); + + httpClient.webSocket(options, clientContext -> { + if (clientContext.succeeded()) { + LOG.debugf("Quinoa Dev WebSocket Client Connected: %s:%s%s", host, port, forwardUri); + clientWs.set(clientContext.result()); + // messages from NodeJS forwarded back to browser + clientWs.get().exceptionHandler( + (e) -> LOG.errorf(e, "Quinoa Dev WebSocket Client closed with error: %s", e.getMessage())) + .closeHandler((__) -> { + LOG.debug("Quinoa Dev WebSocket Client is closed"); + serverWs.close(); + }).textMessageHandler((msg) -> { + LOG.debugf("Quinoa Dev WebSocket Client message: %s", msg); + serverWs.writeTextMessage(msg); + }); + + // messages from browser forwarded to NodeJS + serverWs.textMessageHandler((msg) -> { + LOG.debugf("Quinoa Dev WebSocket Server message: %s", msg); + final WebSocket w = clientWs.get(); + if (w != null && !w.isClosed()) { + w.writeTextMessage(msg); + } + }); + } else { + LOG.error("Quinoa Dev WebSocket Client connection failed", clientContext.cause()); + } + }); + } else { + LOG.error("Error while upgrading request to WebSocket", r.cause()); + } + + }); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java index 1291e6cb..bc9b6023 100644 --- a/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java +++ b/runtime/src/main/java/io/quarkiverse/quinoa/QuinoaRecorder.java @@ -26,9 +26,9 @@ public class QuinoaRecorder { public static final Set HANDLED_METHODS = Set.of(HttpMethod.HEAD, HttpMethod.OPTIONS, HttpMethod.GET); public Handler quinoaProxyDevHandler(final QuinoaHandlerConfig handlerConfig, Supplier vertx, - int port) { + int port, boolean websocket) { logIgnoredPathPrefixes(handlerConfig.ignoredPathPrefixes); - return new QuinoaDevProxyHandler(handlerConfig, vertx.get(), port); + return new QuinoaDevProxyHandler(handlerConfig, vertx.get(), port, websocket); } public Handler quinoaSPARoutingHandler(final QuinoaHandlerConfig handlerConfig) throws IOException {