This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new d6402d4e190f CAMEL-25581: camel-jbang - run the web session on JLine 
instead of Aesh
d6402d4e190f is described below

commit d6402d4e190ffb2a33c2e5b820e148762608572c
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 14 13:35:27 2026 +0200

    CAMEL-25581: camel-jbang - run the web session on JLine instead of Aesh
    
    camel tui --web ran its browser sessions on Aesh, the only use of Aesh in
    Camel, while the local session, the shell panel and camel shell run on
    JLine. A browser session now runs on TamboUI's JLineBackend, as a local one
    does:
    
    - WebTerminal reads the messages of the page (init with its size, read with
      what was typed, resize) into a JLine LineDisciplineTerminal, and sends the
      terminal output back as text frames without splitting a UTF-8 character.
      The size the page asks for is bounded.
    - WebPageHandler serves the page and its assets from the class path, in
      place of Aesh's handler, and refuses paths with '..'.
    - Closing the browser tab ends the session: closing the socket ends the
      input of the terminal (with Aesh the session ran on and failed later), and
      the session ending closes the socket.
    - The tamboui-aesh-backend and Aesh terminal-http dependencies, and the
      aesh-terminal-version property, are gone.
    
    Keys, mouse, resizing, quitting and closing the tab were checked in a
    browser.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md   |   2 +-
 dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml     |  13 +-
 .../jbang/core/commands/tui/TuiBackendHelper.java  |   7 +-
 .../dsl/jbang/core/commands/tui/TuiWebServer.java  |  48 ++---
 .../jbang/core/commands/tui/WebPageHandler.java    | 102 ++++++++++
 .../dsl/jbang/core/commands/tui/WebTerminal.java   | 201 +++++++++++++++++++
 .../src/main/resources/tui/web/index.html          |   4 +-
 .../tui/TuiBackendHelperRecordingTest.java         |  11 +-
 .../jbang/core/commands/tui/TuiWebServerTest.java  |   2 +-
 .../jbang/core/commands/tui/WebTerminalTest.java   | 221 +++++++++++++++++++++
 parent/pom.xml                                     |   1 -
 11 files changed, 559 insertions(+), 53 deletions(-)

diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
index 376e1fc47290..2f81ab59802d 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
@@ -44,7 +44,7 @@ and use each other's package-private members, so there are no 
sub-packages. Clas
 | `TuiHelper` | shared helpers: text width, wrapping and truncation, ANSI, 
number and duration formats, footer hints |
 | `ScreenSanitizer` | cleans every drawn frame of what a terminal would act 
on: control and BiDi characters in text from the integrations |
 | `RecordingManager`, `TapePlayer`, `TapeRecorder` | `--record` and tapes |
-| `TuiWebServer` | `--web`, the terminal in a browser 
(`src/main/resources/tui/web`) |
+| `TuiWebServer`, `WebPageHandler`, `WebTerminal` | `--web`, the terminal in a 
browser: the page (`src/main/resources/tui/web`), and a JLine terminal per 
browser session |
 
 Seven classes are over 2000 lines (`AiPanel`, `SourceViewer`, `CamelMonitor`, 
`HistoryTab`, `DiagramSupport`,
 `MemoryLeakTab`, `SourceEditAssist`): find the method with a search and read 
that range. `SourceSizeTest` keeps them,
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml 
b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
index c108766e09ec..4f1bfbe20bfa 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
@@ -91,18 +91,7 @@
             <artifactId>tamboui-markdown</artifactId>
             <version>${tamboui-version}</version>
         </dependency>
-        <dependency>
-            <groupId>dev.tamboui</groupId>
-            <artifactId>tamboui-aesh-backend</artifactId>
-            <version>${tamboui-version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.aesh</groupId>
-            <artifactId>terminal-http</artifactId>
-            <version>${aesh-terminal-version}</version>
-        </dependency>
-        <!-- Netty is pulled in transitively by terminal-http, but 
TuiWebServer uses these
-             packages directly, so declare them explicitly to keep the 
contract clear. -->
+        <!-- TuiWebServer serves the browser page of camel tui and its 
WebSocket with Netty -->
         <dependency>
             <groupId>io.netty</groupId>
             <artifactId>netty-common</artifactId>
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
index 3a5791ac6e19..7ac2b6fe7f12 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
@@ -30,10 +30,9 @@ final class TuiBackendHelper {
 
     static TuiRunner createTuiRunner() throws Exception {
         Terminal activeTerminal = EnvironmentHelper.getActiveTerminal();
-        // Build the JLine backend explicitly rather than leaving backend 
selection to
-        // TamboUI's ServiceLoader-based auto-discovery: with 
tamboui-aesh-backend also on the
-        // classpath (for --web), auto-discovery can pick AeshBackend for the 
local session too,
-        // which drives a native PosixSysTerminal that doesn't shut down 
cleanly here.
+        // Build the JLine backend explicitly rather than leaving backend 
selection to TamboUI's
+        // ServiceLoader-based auto-discovery, on the terminal camel shell 
already uses when there
+        // is one; a web session (TuiWebServer) runs on a JLineBackend too.
         JLineBackend backend = activeTerminal != null ? new 
JLineBackend(activeTerminal) : new JLineBackend();
         return createTuiRunner(backend);
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java
index f58878b31dbf..531d5fda8244 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java
@@ -27,7 +27,7 @@ import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 
-import dev.tamboui.backend.aesh.AeshBackend;
+import dev.tamboui.backend.jline3.JLineBackend;
 import io.netty.bootstrap.ServerBootstrap;
 import io.netty.channel.Channel;
 import io.netty.channel.ChannelHandlerContext;
@@ -53,18 +53,16 @@ import io.netty.handler.codec.http.HttpServerCodec;
 import io.netty.handler.codec.http.HttpVersion;
 import io.netty.handler.codec.http.QueryStringDecoder;
 import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
-import io.netty.handler.stream.ChunkedWriteHandler;
 import io.netty.util.concurrent.ImmediateEventExecutor;
-import org.aesh.terminal.Connection;
-import org.aesh.terminal.http.netty.HttpRequestHandler;
-import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler;
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.jline.terminal.Terminal;
 
 /**
- * Serves the Camel TUI dashboard to a web browser over WebSocket, using 
Aesh's HTTP/WebSocket terminal bridge.
+ * Serves the Camel TUI dashboard to a web browser over WebSocket: the page 
({@link WebPageHandler}), then a terminal
+ * per browser session ({@link WebTerminal}).
  * <p>
- * Each incoming connection gets its own {@link CamelMonitor} instance 
(running the same live-monitoring logic as a
- * local terminal session) driven by an {@link AeshBackend} wrapping that 
connection.
+ * Each session gets its own {@link CamelMonitor} instance (running the same 
live-monitoring logic as a local terminal
+ * session) on a {@link JLineBackend}, as a local session runs (CAMEL-25581).
  * <p>
  * Binds to 127.0.0.1 only for security.
  */
@@ -91,7 +89,7 @@ class TuiWebServer {
                 t.setDaemon(true);
                 return t;
             });
-    private final Consumer<Connection> sessionHandler;
+    private final Consumer<Terminal> sessionHandler;
     private Channel serverChannel;
     private boolean stopped;
 
@@ -105,7 +103,7 @@ class TuiWebServer {
      * {@link CamelMonitor}.
      */
     TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader, 
String name, long refreshInterval,
-                 String theme, Consumer<Connection> sessionHandler) {
+                 String theme, Consumer<Terminal> sessionHandler) {
         this.port = port;
         this.main = main;
         this.classLoader = classLoader;
@@ -173,43 +171,42 @@ class TuiWebServer {
         return bossDone && workerDone && sessionDone;
     }
 
-    private void accept(Connection connection) {
+    private boolean startSession(Terminal terminal, Runnable ended) {
         try {
             sessionExecutor.submit(() -> {
                 try {
-                    sessionHandler.accept(connection);
+                    sessionHandler.accept(terminal);
                 } finally {
-                    closeQuietly(connection);
+                    closeQuietly(terminal);
+                    ended.run();
                 }
             });
+            return true;
         } catch (RejectedExecutionException e) {
             LOG.log(Level.WARNING, "Web TUI session rejected: server is 
stopping or too many concurrent sessions");
-            closeQuietly(connection);
+            closeQuietly(terminal);
+            return false;
         }
     }
 
-    private void runSession(Connection connection) {
+    private void runSession(Terminal terminal) {
         try {
-            AeshBackend backend = new AeshBackend(connection);
             CamelMonitor monitor = new CamelMonitor(main, classLoader);
             monitor.name = name;
             monitor.refreshInterval = refreshInterval;
             monitor.theme = theme;
-            monitor.webBackend = backend;
+            monitor.webBackend = new JLineBackend(terminal);
             monitor.call();
-        } catch (NullPointerException e) {
-            // Browser disconnected mid-render — Aesh websocket handler's 
context is null
-            LOG.log(Level.DEBUG, "Web TUI session disconnected", e);
         } catch (Exception e) {
             LOG.log(Level.WARNING, "Web TUI session ended with an error", e);
         }
     }
 
-    private static void closeQuietly(Connection connection) {
+    private static void closeQuietly(Terminal terminal) {
         try {
-            connection.close();
+            terminal.close();
         } catch (Exception ignored) {
-            // connection already closing
+            // the terminal is already closed
         }
     }
 
@@ -219,13 +216,12 @@ class TuiWebServer {
         protected void initChannel(SocketChannel channel) {
             ChannelPipeline pipeline = channel.pipeline();
             pipeline.addLast(new HttpServerCodec());
-            pipeline.addLast(new ChunkedWriteHandler());
             pipeline.addLast(new HttpObjectAggregator(65_536));
             pipeline.addLast(new OriginCheckingUpgradeHandler());
             pipeline.addLast(new SecurityHeadersHandler());
-            pipeline.addLast(new HttpRequestHandler("/ws", "/tui/web"));
+            pipeline.addLast(new WebPageHandler("/ws"));
             pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
-            pipeline.addLast(new TtyWebSocketFrameHandler(channels, 
TuiWebServer.this::accept));
+            pipeline.addLast(new WebTerminal(channels, 
TuiWebServer.this::startSession));
         }
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/WebPageHandler.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/WebPageHandler.java
new file mode 100644
index 000000000000..b1b09bcaaf20
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/WebPageHandler.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpMethod;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpUtil;
+import io.netty.handler.codec.http.HttpVersion;
+import io.netty.handler.codec.http.QueryStringDecoder;
+
+/**
+ * Serves the page of {@code camel tui --web} and its assets from the class 
path, {@code /tui/web} ({@code /} is its
+ * {@code index.html}); a request for the WebSocket path goes on down the 
pipeline. A path is plain names separated by
+ * {@code /}, with no {@code ..}, and only the file types the page uses are 
served.
+ */
+final class WebPageHandler extends 
SimpleChannelInboundHandler<FullHttpRequest> {
+
+    private static final String ROOT = "/tui/web";
+    private static final Pattern PATH = Pattern.compile("(/[A-Za-z0-9._-]+)+");
+    private static final Map<String, String> CONTENT_TYPES = Map.of(
+            "html", "text/html; charset=UTF-8",
+            "js", "application/javascript; charset=UTF-8",
+            "css", "text/css; charset=UTF-8",
+            "png", "image/png",
+            "txt", "text/plain; charset=UTF-8");
+
+    private final String webSocketPath;
+
+    WebPageHandler(String webSocketPath) {
+        this.webSocketPath = webSocketPath;
+    }
+
+    @Override
+    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest 
request) {
+        String path = new QueryStringDecoder(request.uri()).path();
+        if (webSocketPath.equalsIgnoreCase(path)) {
+            ctx.fireChannelRead(request.retain());
+            return;
+        }
+        FullHttpResponse response;
+        if (!HttpMethod.GET.equals(request.method())) {
+            response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, 
HttpResponseStatus.METHOD_NOT_ALLOWED);
+        } else {
+            byte[] content = load("/".equals(path) ? "/index.html" : path);
+            String type = CONTENT_TYPES.get(path.equals("/") ? "html" : 
path.substring(path.lastIndexOf('.') + 1));
+            if (content == null || type == null) {
+                response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, 
HttpResponseStatus.NOT_FOUND);
+            } else {
+                response = new DefaultFullHttpResponse(
+                        HttpVersion.HTTP_1_1, HttpResponseStatus.OK, 
Unpooled.wrappedBuffer(content));
+                response.headers().set(HttpHeaderNames.CONTENT_TYPE, type);
+            }
+        }
+        HttpUtil.setContentLength(response, 
response.content().readableBytes());
+        boolean keepAlive = HttpUtil.isKeepAlive(request);
+        HttpUtil.setKeepAlive(response, keepAlive);
+        if (keepAlive) {
+            ctx.writeAndFlush(response);
+        } else {
+            
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+        }
+    }
+
+    /** The resource at a path below the root, or null when there is none, or 
the path is not a plain one. */
+    static byte[] load(String path) {
+        if (!PATH.matcher(path).matches() || path.contains("..")) {
+            return null;
+        }
+        try (InputStream in = WebPageHandler.class.getResourceAsStream(ROOT + 
path)) {
+            return in != null ? in.readAllBytes() : null;
+        } catch (IOException e) {
+            return null;
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/WebTerminal.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/WebTerminal.java
new file mode 100644
index 000000000000..30f2e8f077fc
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/WebTerminal.java
@@ -0,0 +1,201 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.System.Logger;
+import java.lang.System.Logger.Level;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.function.Consumer;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.channel.group.ChannelGroup;
+import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.jline.terminal.Size;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.impl.LineDisciplineTerminal;
+
+/**
+ * The terminal of one browser session of {@code camel tui --web} 
(CAMEL-25581). The page (xterm.js) talks JSON over the
+ * WebSocket: {@code init} with its size, {@code read} with what the user 
typed, {@code resize} with the new size; it
+ * gets the terminal output back as text frames. Here that becomes a JLine 
{@link LineDisciplineTerminal}, so a web
+ * session runs on TamboUI's JLineBackend as a local one does. Closing the 
socket ends the input of the terminal, which
+ * ends the session; the session ending closes the socket.
+ */
+final class WebTerminal extends 
SimpleChannelInboundHandler<TextWebSocketFrame> {
+
+    private static final Logger LOG = 
System.getLogger(WebTerminal.class.getName());
+    // the page is always xterm.js
+    private static final String TYPE = "xterm-256color";
+    // a size the page cannot inflate into a screen buffer too big to allocate
+    static final int MAX_COLUMNS = 1000;
+    static final int MAX_ROWS = 500;
+
+    /** Runs the sessions of the server. */
+    interface Sessions {
+
+        /**
+         * Starts a session on the terminal, and runs {@code ended} when it is 
over. Returns false when it cannot start
+         * one (the server stops, or has too many).
+         */
+        boolean start(Terminal terminal, Runnable ended);
+    }
+
+    private final ChannelGroup channels;
+    private final Sessions sessions;
+    private LineDisciplineTerminal terminal;
+
+    WebTerminal(ChannelGroup channels, Sessions sessions) {
+        this.channels = channels;
+        this.sessions = sessions;
+    }
+
+    @Override
+    public void handlerAdded(ChannelHandlerContext ctx) {
+        // the server closes the sockets of the open sessions when it stops
+        channels.add(ctx.channel());
+    }
+
+    @Override
+    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame 
frame) throws IOException {
+        JsonObject message;
+        try {
+            message = (JsonObject) Jsoner.deserialize(frame.text());
+        } catch (Exception e) {
+            LOG.log(Level.DEBUG, "Web TUI: ignoring a message that is not 
JSON");
+            return;
+        }
+        String action = message.get("action") instanceof String a ? a : "";
+        if ("init".equals(action) && terminal == null) {
+            open(ctx.channel(), message);
+        } else if ("read".equals(action) && terminal != null && 
message.get("data") instanceof String data) {
+            terminal.processInputBytes(data.getBytes(StandardCharsets.UTF_8));
+        } else if ("resize".equals(action) && terminal != null) {
+            terminal.setSize(size(message));
+            terminal.raise(Terminal.Signal.WINCH);
+        }
+    }
+
+    private void open(Channel channel, JsonObject init) throws IOException {
+        FrameOutput output = new FrameOutput(text -> {
+            if (channel.isActive()) {
+                channel.writeAndFlush(new TextWebSocketFrame(text));
+            }
+        });
+        terminal = new LineDisciplineTerminal("camel-tui-web", TYPE, output, 
StandardCharsets.UTF_8);
+        terminal.setSize(size(init));
+        if (!sessions.start(terminal, channel::close)) {
+            channel.close();
+        }
+    }
+
+    static Size size(JsonObject message) {
+        return new Size(bounded(message.get("cols"), 80, MAX_COLUMNS), 
bounded(message.get("rows"), 24, MAX_ROWS));
+    }
+
+    private static int bounded(Object value, int otherwise, int max) {
+        return value instanceof Number n ? Math.max(1, Math.min(max, 
n.intValue())) : otherwise;
+    }
+
+    @Override
+    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+        // the browser went away: the end of the input ends the session
+        if (terminal != null) {
+            try {
+                terminal.close();
+            } catch (IOException e) {
+                LOG.log(Level.DEBUG, "Web TUI: closing the terminal failed", 
e);
+            }
+        }
+        super.channelInactive(ctx);
+    }
+
+    @Override
+    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+        LOG.log(Level.DEBUG, "Web TUI: closing the socket after an error", 
cause);
+        ctx.close();
+    }
+
+    /**
+     * The output of the terminal, handed on as text when it is flushed 
(TamboUI flushes after each frame it draws). A
+     * UTF-8 sequence split between two flushes is kept until it is whole, so 
a character is never cut in two.
+     */
+    static final class FrameOutput extends OutputStream {
+
+        // past this, the text is handed on without waiting for a flush
+        private static final int MAX_PENDING = 256 * 1024;
+
+        private final Consumer<String> sink;
+        private final CharsetDecoder decoder = 
StandardCharsets.UTF_8.newDecoder()
+                .onMalformedInput(CodingErrorAction.REPLACE)
+                .onUnmappableCharacter(CodingErrorAction.REPLACE);
+        private ByteBuffer pending = ByteBuffer.allocate(8192);
+
+        FrameOutput(Consumer<String> sink) {
+            this.sink = sink;
+        }
+
+        @Override
+        public synchronized void write(int b) {
+            room(1);
+            pending.put((byte) b);
+        }
+
+        @Override
+        public synchronized void write(byte[] bytes, int offset, int length) {
+            room(length);
+            pending.put(bytes, offset, length);
+            if (pending.position() > MAX_PENDING) {
+                flush();
+            }
+        }
+
+        private void room(int length) {
+            if (pending.remaining() < length) {
+                ByteBuffer larger = 
ByteBuffer.allocate(Math.max(pending.capacity() * 2, pending.position() + 
length));
+                pending.flip();
+                larger.put(pending);
+                pending = larger;
+            }
+        }
+
+        @Override
+        public synchronized void flush() {
+            if (pending.position() == 0) {
+                return;
+            }
+            pending.flip();
+            CharBuffer text = CharBuffer.allocate(pending.remaining());
+            // the end of a sequence that is not complete yet stays in pending
+            decoder.decode(pending, text, false);
+            pending.compact();
+            text.flip();
+            if (text.hasRemaining()) {
+                sink.accept(text.toString());
+            }
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html
index c9b009654e7c..8f6641aa29d3 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html
@@ -209,8 +209,8 @@
 
           function sendInit() {
             fitAddon.fit();
-            // Message shape ({action: 'init'|'read'|'resize', ...}) is 
dictated by
-            // org.aesh:terminal-http's server-side HttpTtyConnection - it is 
not ours to change.
+            // Message shape ({action: 'init'|'read'|'resize', ...}) is what 
WebTerminal on the
+            // server reads; it answers with the terminal output as text 
frames.
             socket.send(JSON.stringify({
               action: 'init',
               type: 'xterm-256color',
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java
index 0f446adf9ac7..d806b3d4fb7d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java
@@ -33,12 +33,11 @@ import static org.assertj.core.api.Assertions.assertThat;
 /**
  * Verifies that {@code camel tui --record} actually engages TamboUI's 
recording backend.
  * <p>
- * camel-tui must supply an explicit {@link 
dev.tamboui.backend.jline3.JLineBackend} (auto-discovery would otherwise
- * pick the Aesh backend that is on the classpath for {@code --web}). Before 
TamboUI 0.5.0 only backends created by
- * TamboUI's own factory were wrapped for recording, so camel-tui wrapped its 
explicit backend itself; since 0.5.0
- * {@code TuiRunner.create} does that for explicit backends too. When the 
wrapping goes missing, {@code --record}
- * replays no tape and writes no {@code .cast} file, yet still exits cleanly, 
so only a test like this one catches the
- * regression.
+ * camel-tui supplies an explicit {@link 
dev.tamboui.backend.jline3.JLineBackend} rather than leaving the choice to
+ * TamboUI's auto-discovery. Before TamboUI 0.5.0 only backends created by 
TamboUI's own factory were wrapped for
+ * recording, so camel-tui wrapped its explicit backend itself; since 0.5.0 
{@code TuiRunner.create} does that for
+ * explicit backends too. When the wrapping goes missing, {@code --record} 
replays no tape and writes no {@code .cast}
+ * file, yet still exits cleanly, so only a test like this one catches the 
regression.
  */
 // RecordingConfig.load() also reads fps and duration, so every key camel-tui 
sets has to be managed here: a value
 // leaking out of this class would silently reconfigure recording in an 
unrelated test. junit-pioneer clears each key
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java
index 71e62f463ed4..87dc10a3eefc 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java
@@ -213,7 +213,7 @@ class TuiWebServerTest {
     private static TuiWebServer newServerWithNoOpSession(int port) {
         return new TuiWebServer(
                 port, new CamelJBangMain(), 
Thread.currentThread().getContextClassLoader(), null, 200,
-                "dark", connection -> {
+                "dark", terminal -> {
                 });
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/WebTerminalTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/WebTerminalTest.java
new file mode 100644
index 000000000000..014c0775324b
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/WebTerminalTest.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.WebSocket;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.AvailablePortFinder.Port;
+import org.apache.camel.util.json.JsonObject;
+import org.jline.terminal.Size;
+import org.jline.terminal.Terminal;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * A browser session of {@code camel tui --web} runs on a JLine terminal fed 
from the WebSocket (CAMEL-25581).
+ */
+class WebTerminalTest {
+
+    // characters of 2, 3 and 4 bytes in UTF-8
+    private static final String WIDE = "h\u00e9\u20ac\uD83D\uDE00";
+
+    private TuiWebServer server;
+
+    @AfterEach
+    void stopServer() {
+        if (server != null) {
+            server.stop();
+        }
+    }
+
+    /** The page: talks the protocol of index.html, and keeps what the server 
sends. */
+    private static final class Page implements WebSocket.Listener {
+        final StringBuffer received = new StringBuffer();
+        final CompletableFuture<Integer> closed = new CompletableFuture<>();
+
+        @Override
+        public CompletionStage<?> onText(WebSocket socket, CharSequence data, 
boolean last) {
+            received.append(data);
+            socket.request(1);
+            return null;
+        }
+
+        @Override
+        public CompletionStage<?> onClose(WebSocket socket, int statusCode, 
String reason) {
+            closed.complete(statusCode);
+            return null;
+        }
+
+        @Override
+        public void onError(WebSocket socket, Throwable error) {
+            closed.completeExceptionally(error);
+        }
+    }
+
+    private TuiWebServer start(int port, Consumer<Terminal> session) throws 
IOException {
+        server = new TuiWebServer(port, new CamelJBangMain(), 
getClass().getClassLoader(), null, 200, "dark", session);
+        server.start();
+        return server;
+    }
+
+    private static WebSocket connect(int port, Page page) throws Exception {
+        return HttpClient.newHttpClient().newWebSocketBuilder()
+                .buildAsync(URI.create("ws://127.0.0.1:" + port + "/ws"), page)
+                .get(5, TimeUnit.SECONDS);
+    }
+
+    private static String message(String action, Object... keysAndValues) {
+        JsonObject json = new JsonObject();
+        json.put("action", action);
+        for (int i = 0; i + 1 < keysAndValues.length; i += 2) {
+            json.put((String) keysAndValues[i], keysAndValues[i + 1]);
+        }
+        return json.toJson();
+    }
+
+    @Test
+    void aSessionRunsOnAJLineTerminalFedFromTheSocket() throws Exception {
+        List<Size> sizes = new CopyOnWriteArrayList<>();
+        CountDownLatch inputEnded = new CountDownLatch(1);
+        Consumer<Terminal> session = terminal -> {
+            try {
+                sizes.add(terminal.getSize());
+                terminal.handle(Terminal.Signal.WINCH, signal -> 
sizes.add(terminal.getSize()));
+                terminal.enterRawMode();
+                terminal.writer().print("ready");
+                terminal.writer().flush();
+                StringBuilder line = new StringBuilder();
+                int c;
+                while ((c = terminal.reader().read()) != -1 && c != '\r') {
+                    line.append((char) c);
+                }
+                terminal.writer().print("you typed: " + line);
+                terminal.writer().flush();
+                // the session runs until the page goes away: the end of the 
input
+                while (terminal.reader().read() != -1) {
+                    // nothing more to read
+                }
+                inputEnded.countDown();
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+        };
+        try (Port reserved = AvailablePortFinder.find()) {
+            start(reserved.getPort(), session);
+            Page page = new Page();
+            WebSocket socket = connect(reserved.getPort(), page);
+
+            socket.sendText(message("init", "cols", 100, "rows", 30), 
true).get(5, TimeUnit.SECONDS);
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
page.received.toString().contains("ready"));
+            assertThat(sizes).first().isEqualTo(new Size(100, 30));
+
+            socket.sendText(message("read", "data", WIDE + "\r"), true).get(5, 
TimeUnit.SECONDS);
+            await().atMost(5, TimeUnit.SECONDS)
+                    .untilAsserted(() -> 
assertThat(page.received.toString()).contains("you typed: " + WIDE));
+
+            socket.sendText(message("resize", "cols", 120, "rows", 40), 
true).get(5, TimeUnit.SECONDS);
+            await().atMost(5, TimeUnit.SECONDS).until(() -> sizes.contains(new 
Size(120, 40)));
+
+            // closing the page ends the input of the terminal, and so the 
session
+            socket.sendClose(WebSocket.NORMAL_CLOSURE, "bye").get(5, 
TimeUnit.SECONDS);
+            assertThat(inputEnded.await(5, TimeUnit.SECONDS)).isTrue();
+        }
+    }
+
+    @Test
+    void theSessionEndingClosesTheSocket() throws Exception {
+        try (Port reserved = AvailablePortFinder.find()) {
+            start(reserved.getPort(), terminal -> {
+            });
+            Page page = new Page();
+            WebSocket socket = connect(reserved.getPort(), page);
+
+            socket.sendText(message("init", "cols", 80, "rows", 24), 
true).get(5, TimeUnit.SECONDS);
+
+            assertThat(page.closed.get(5, TimeUnit.SECONDS)).isNotNull();
+        }
+    }
+
+    @Test
+    void theSizeIsBounded() {
+        JsonObject huge = new JsonObject();
+        huge.put("cols", 100000);
+        huge.put("rows", -5);
+
+        assertThat(WebTerminal.size(huge)).isEqualTo(new 
Size(WebTerminal.MAX_COLUMNS, 1));
+        assertThat(WebTerminal.size(new JsonObject())).isEqualTo(new Size(80, 
24));
+    }
+
+    @Test
+    void theOutputNeverSplitsACharacter() {
+        byte[] bytes = WIDE.getBytes(StandardCharsets.UTF_8);
+        for (int cut = 0; cut <= bytes.length; cut++) {
+            StringBuilder frames = new StringBuilder();
+            WebTerminal.FrameOutput out = new 
WebTerminal.FrameOutput(frames::append);
+            out.write(bytes, 0, cut);
+            out.flush();
+            out.write(bytes, cut, bytes.length - cut);
+            out.flush();
+            assertThat(frames.toString()).as("cut after byte %d", 
cut).isEqualTo(WIDE);
+        }
+    }
+
+    @Test
+    void thePageIsServedFromTheClassPathAndNothingElse() throws Exception {
+        assertThat(WebPageHandler.load("/index.html")).isNotNull();
+        assertThat(WebPageHandler.load("/vendor/xterm.js")).isNotNull();
+        
assertThat(WebPageHandler.load("/../../META-INF/MANIFEST.MF")).isNull();
+        assertThat(WebPageHandler.load("/vendor/../index.html")).isNull();
+        assertThat(WebPageHandler.load("index.html")).isNull();
+        try (Port reserved = AvailablePortFinder.find()) {
+            start(reserved.getPort(), terminal -> {
+            });
+            HttpClient client = HttpClient.newHttpClient();
+            String base = "http://127.0.0.1:"; + reserved.getPort();
+
+            HttpResponse<String> missing = client.send(
+                    HttpRequest.newBuilder(URI.create(base + 
"/vendor/missing.js")).build(),
+                    HttpResponse.BodyHandlers.ofString());
+            HttpResponse<String> post = client.send(
+                    HttpRequest.newBuilder(URI.create(base + "/index.html"))
+                            
.POST(HttpRequest.BodyPublishers.ofString("x")).build(),
+                    HttpResponse.BodyHandlers.ofString());
+
+            assertThat(missing.statusCode()).isEqualTo(404);
+            assertThat(post.statusCode()).isEqualTo(405);
+        }
+    }
+}
diff --git a/parent/pom.xml b/parent/pom.xml
index f95353475cff..1f2136b02a1e 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -61,7 +61,6 @@
         <activemq-version>5.19.11</activemq-version>
         <activemq6-version>6.3.2</activemq6-version>
         <activemq-artemis-version>2.57.0</activemq-artemis-version>
-        <aesh-terminal-version>3.18.3</aesh-terminal-version>
         <agroal-version>3.2.1</agroal-version>
         <allegro-converter-version>0.3.0</allegro-converter-version>
         <alibabacloud-oss-version>0.6.0</alibabacloud-oss-version>

Reply via email to