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>