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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 4369f411e8 TODO-119/120 - Reactive-streams REST returns (JDK 
Flow.Publisher core + Reactor/RxJava bridge) as opt-in 
juneau-rest-server-reactive / juneau-rest-server-reactor modules
4369f411e8 is described below

commit 4369f411e8f91007db39db3be8b3d62a4f99dd49
Author: James Bognar <[email protected]>
AuthorDate: Fri May 29 12:31:02 2026 -0400

    TODO-119/120 - Reactive-streams REST returns (JDK Flow.Publisher core + 
Reactor/RxJava bridge) as opt-in juneau-rest-server-reactive / 
juneau-rest-server-reactor modules
---
 juneau-rest/juneau-rest-mock/pom.xml               |  11 +
 .../reactive/ReactiveOptIn_BareServer_Test.java    |  83 ++++
 .../pom.xml                                        |  74 ++--
 .../apache/juneau/rest/reactive/Adaptation.java    |  95 +++++
 .../rest/reactive/ReactiveResponseProcessor.java   | 442 +++++++++++++++++++++
 .../rest/reactive/ReactiveStreamsAdapter.java      |  78 ++++
 .../apache/juneau/rest/reactive/package-info.java  |  54 +++
 ....apache.juneau.rest.processor.ResponseProcessor |   1 +
 juneau-rest/juneau-rest-server-reactor/pom.xml     | 140 +++++++
 .../bridge/ReactiveStreamsPublisherAdapter.java    |  47 +++
 .../reactive/bridge/ReactorReactiveAdapter.java    |  54 +++
 .../reactive/bridge/RxJavaReactiveAdapter.java     |  64 +++
 .../juneau/rest/reactive/bridge/package-info.java  |  46 +++
 ...che.juneau.rest.reactive.ReactiveStreamsAdapter |   3 +
 .../java/org/apache/juneau/rest/RestContext.java   |  59 +++
 juneau-rest/pom.xml                                |   2 +
 juneau-utest/pom.xml                               |  49 +++
 .../reactive/ReactiveResponseProcessor_Test.java   | 251 ++++++++++++
 .../rest/reactive/bridge/ReactiveBridge_Test.java  | 191 +++++++++
 juneau-utest/test-run-history.tsv                  |   1 +
 20 files changed, 1716 insertions(+), 29 deletions(-)

diff --git a/juneau-rest/juneau-rest-mock/pom.xml 
b/juneau-rest/juneau-rest-mock/pom.xml
index 838edfe777..ab51c1f18c 100644
--- a/juneau-rest/juneau-rest-mock/pom.xml
+++ b/juneau-rest/juneau-rest-mock/pom.xml
@@ -50,6 +50,17 @@
                        <artifactId>juneau-rest-client-classic</artifactId>
                        <version>${project.version}</version>
                </dependency>
+               <!--
+                       Test-only: the "bare server has zero reactive behavior" 
guard (ReactiveOptIn_BareServer_Test).
+                       This module's test classpath intentionally has NO 
reactive module, so it is the only place the
+                       opt-in contract can be asserted from the absence side. 
See FINISHED-119/120.
+               -->
+               <dependency>
+                       <groupId>org.junit.jupiter</groupId>
+                       <artifactId>junit-jupiter</artifactId>
+                       <version>${junit.version}</version>
+                       <scope>test</scope>
+               </dependency>
        </dependencies>
 
        <build>
diff --git 
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/reactive/ReactiveOptIn_BareServer_Test.java
 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/reactive/ReactiveOptIn_BareServer_Test.java
new file mode 100644
index 0000000000..89a4c0ba2d
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/reactive/ReactiveOptIn_BareServer_Test.java
@@ -0,0 +1,83 @@
+/*
+ * 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.juneau.rest.reactive;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.*;
+
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that a <b>bare</b> {@code juneau-rest-server} (no {@code 
juneau-rest-server-reactive} /
+ * {@code juneau-rest-server-reactor} module on the classpath) has <b>zero</b> 
reactive behavior.
+ *
+ * <p>
+ * This module's test classpath intentionally has no reactive module, so 
{@code RestContext}'s
+ * {@link java.util.ServiceLoader}-based response-processor discovery finds no 
provider and
+ * {@code ReactiveResponseProcessor} is never added to the chain. A {@code 
@RestOp} returning a
+ * {@link java.util.concurrent.Flow.Publisher Flow.Publisher} is therefore 
treated as an ordinary POJO
+ * return value and serialized through the normal serializer chain &mdash; it 
is NOT collected into a
+ * JSON array (the reactive buffer shape) and NOT streamed.
+ *
+ * <p>
+ * The positive activation tests live in {@code juneau-utest} (where the 
reactive module IS on the
+ * classpath); this negative test cannot live there because the module's 
presence auto-activates the
+ * processor for every resource. See {@code FINISHED-119}/{@code FINISHED-120} 
for the opt-in design.
+ */
+class ReactiveOptIn_BareServer_Test {
+
+       public static final class Pojo {
+               public final String name;
+               public final int value;
+               public Pojo(String name, int value) { this.name = name; 
this.value = value; }
+       }
+
+       /** Minimal JDK Flow.Publisher; on a bare server it is never subscribed 
to (no reactive processor). */
+       static final class ListPublisher<T> implements Flow.Publisher<T> {
+               @Override public void subscribe(Flow.Subscriber<? super T> sub) 
{
+                       sub.onSubscribe(new Flow.Subscription() {
+                               @Override public void request(long n) { /* 
no-op */ }
+                               @Override public void cancel() { /* no-op */ }
+                       });
+               }
+       }
+
+       @Rest(serializers = JsonSerializer.class)
+       public static class A {
+               @RestGet("/flux")
+               public Flow.Publisher<Pojo> flux() {
+                       return new ListPublisher<>();
+               }
+       }
+
+       private static final MockRestClient CA = 
MockRestClient.buildLax(A.class);
+
+       @Test void a01_bareServer_doesNotProcessFlowPublisherReactively() 
throws Exception {
+               var content = 
CA.get("/flux").accept("application/json").run().getContent().asString();
+               // Reactive (buffer-shape) handling would have subscribed to 
the publisher and produced a JSON
+               // array of the emitted Pojo elements. With no reactive module 
on the classpath the publisher is
+               // instead serialized as a plain (property-less) bean — so the 
element payload must be absent.
+               assertFalse(content.contains("\"name\""),
+                       "Bare juneau-rest-server must not reactively process 
Flow.Publisher returns. Body was: " + content);
+               assertFalse(content.startsWith("["),
+                       "Bare juneau-rest-server must not buffer a 
Flow.Publisher into a JSON array. Body was: " + content);
+       }
+}
diff --git a/juneau-rest/juneau-rest-mock/pom.xml 
b/juneau-rest/juneau-rest-server-reactive/pom.xml
similarity index 63%
copy from juneau-rest/juneau-rest-mock/pom.xml
copy to juneau-rest/juneau-rest-server-reactive/pom.xml
index 838edfe777..3ed903b345 100644
--- a/juneau-rest/juneau-rest-mock/pom.xml
+++ b/juneau-rest/juneau-rest-server-reactive/pom.xml
@@ -25,9 +25,9 @@
                <version>9.5.0-SNAPSHOT</version>
        </parent>
 
-       <artifactId>juneau-rest-mock</artifactId>
-       <name>Apache Juneau REST Mock</name>
-       <description>Apache Juneau REST mock API</description>
+       <artifactId>juneau-rest-server-reactive</artifactId>
+       <name>Apache Juneau REST Server Reactive Streams Core</name>
+       <description>Apache Juneau REST Server - JDK-native Flow.Publisher 
return-type support + ReactiveStreamsAdapter SPI (opt-in, 
dependency-free)</description>
        <packaging>bundle</packaging>
 
        <properties>
@@ -35,25 +35,35 @@
        </properties>
 
        <dependencies>
+               <!--
+                       Dependency-free by design.  This module carries only 
the JDK-native Flow.Publisher
+                       response processor, the ReactiveStreamsAdapter SPI, and 
the Adaptation value type, plus
+                       the META-INF/services/...ResponseProcessor file that 
auto-registers the processor when
+                       this jar is on the classpath.  No third-party reactive 
library is referenced — the
+                       Reactor / RxJava / Reactive-Streams adapters live in 
juneau-rest-server-reactor.
+               -->
                <dependency>
                        <groupId>org.apache.juneau</groupId>
                        <artifactId>juneau-rest-server</artifactId>
                        <version>${project.version}</version>
                </dependency>
-               <dependency>
-                       <groupId>org.apache.juneau</groupId>
-                       <artifactId>juneau-rest-client</artifactId>
-                       <version>${project.version}</version>
-               </dependency>
-               <dependency>
-                       <groupId>org.apache.juneau</groupId>
-                       <artifactId>juneau-rest-client-classic</artifactId>
-                       <version>${project.version}</version>
-               </dependency>
        </dependencies>
 
        <build>
                <plugins>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-source-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>attach-sources</id>
+                                               <phase>verify</phase>
+                                               <goals>
+                                                       <goal>jar-no-fork</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
                        <plugin>
                                <groupId>org.apache.felix</groupId>
                                <artifactId>maven-bundle-plugin</artifactId>
@@ -61,25 +71,12 @@
                                <configuration>
                                        
<supportIncrementalBuild>true</supportIncrementalBuild>
                                </configuration>
-                               <executions>
-                               <execution>
-                                       <id>bundle-manifest</id>
-                                       <phase>process-classes</phase>
-                                       <goals>
-                                               <goal>manifest</goal>
-                                       </goals>
-                               </execution>
-                       </executions>
-                       </plugin>
-                       <plugin>
-                               <groupId>org.apache.maven.plugins</groupId>
-                               <artifactId>maven-source-plugin</artifactId>
                                <executions>
                                        <execution>
-                                               <id>attach-sources</id>
-                                               <phase>verify</phase>
+                                               <id>bundle-manifest</id>
+                                               <phase>process-classes</phase>
                                                <goals>
-                                                       <goal>jar-no-fork</goal>
+                                                       <goal>manifest</goal>
                                                </goals>
                                        </execution>
                                </executions>
@@ -88,6 +85,25 @@
                                <groupId>org.apache.maven.plugins</groupId>
                                <artifactId>maven-jar-plugin</artifactId>
                        </plugin>
+                       <plugin>
+                               <groupId>org.jacoco</groupId>
+                               <artifactId>jacoco-maven-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>default-prepare-agent</id>
+                                               <goals>
+                                                       
<goal>prepare-agent</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-report</id>
+                                               <phase>prepare-package</phase>
+                                               <goals>
+                                                       <goal>report</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
                </plugins>
        </build>
 </project>
diff --git 
a/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/Adaptation.java
 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/Adaptation.java
new file mode 100644
index 0000000000..5042f9e7a0
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/Adaptation.java
@@ -0,0 +1,95 @@
+/*
+ * 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.juneau.rest.reactive;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.concurrent.*;
+
+/**
+ * The result of a {@link ReactiveStreamsAdapter#adapt(Object) 
ReactiveStreamsAdapter.adapt(...)} call:
+ * either a single-value {@link CompletionStage} or a multi-value
+ * {@link java.util.concurrent.Flow.Publisher Flow.Publisher}.
+ *
+ * <p>
+ * Exactly one of the two shapes is present. Single-value adaptations (e.g. 
Reactor {@code Mono},
+ * RxJava {@code Single} / {@code Maybe}) collapse onto the existing {@code 
CompletableFuture} async
+ * response path. Streaming adaptations (e.g. Reactor {@code Flux}, RxJava 
{@code Flowable} /
+ * {@code Observable}, a Reactive-Streams {@code Publisher}) are subscribed to 
and rendered as a
+ * buffered list, an SSE stream, or an NDJSON stream depending on the 
negotiated response media type.
+ *
+ * @see ReactiveStreamsAdapter
+ * @see ReactiveResponseProcessor
+ * @since 9.5.0
+ */
+public final class Adaptation {
+
+       private final CompletionStage<?> single;
+       private final java.util.concurrent.Flow.Publisher<?> stream;
+
+       private Adaptation(CompletionStage<?> single, 
java.util.concurrent.Flow.Publisher<?> stream) {
+               this.single = single;
+               this.stream = stream;
+       }
+
+       /**
+        * Creates a single-value adaptation backed by a {@link 
CompletionStage}.
+        *
+        * @param value The completion stage. Must not be {@code null}.
+        * @return A new adaptation.
+        */
+       public static Adaptation single(CompletionStage<?> value) {
+               return new Adaptation(assertArgNotNull("value", value), null);
+       }
+
+       /**
+        * Creates a streaming adaptation backed by a {@link 
java.util.concurrent.Flow.Publisher Flow.Publisher}.
+        *
+        * @param value The publisher. Must not be {@code null}.
+        * @return A new adaptation.
+        */
+       public static Adaptation stream(java.util.concurrent.Flow.Publisher<?> 
value) {
+               return new Adaptation(null, assertArgNotNull("value", value));
+       }
+
+       /**
+        * Returns whether this adaptation is a multi-value stream (vs. a 
single value).
+        *
+        * @return {@code true} if this is a {@link 
java.util.concurrent.Flow.Publisher Flow.Publisher} adaptation.
+        */
+       public boolean isStream() {
+               return stream != null;
+       }
+
+       /**
+        * Returns the single-value completion stage, or {@code null} if this 
is a streaming adaptation.
+        *
+        * @return The completion stage, or {@code null}.
+        */
+       public CompletionStage<?> single() {
+               return single;
+       }
+
+       /**
+        * Returns the streaming publisher, or {@code null} if this is a 
single-value adaptation.
+        *
+        * @return The publisher, or {@code null}.
+        */
+       public java.util.concurrent.Flow.Publisher<?> stream() {
+               return stream;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/ReactiveResponseProcessor.java
 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/ReactiveResponseProcessor.java
new file mode 100644
index 0000000000..4fae418237
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/ReactiveResponseProcessor.java
@@ -0,0 +1,442 @@
+/*
+ * 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.juneau.rest.reactive;
+
+import static jakarta.servlet.http.HttpServletResponse.*;
+
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.Flow;
+import java.util.concurrent.atomic.*;
+import java.util.function.*;
+import java.util.logging.*;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.marshaller.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.util.*;
+import org.apache.juneau.serializer.*;
+import org.apache.juneau.sse.*;
+
+/**
+ * Response processor that bridges <a class="doclink" 
href="https://www.reactive-streams.org/";>Reactive Streams</a>
+ * return values from {@code @RestOp} handlers to Juneau's response pipeline.
+ *
+ * <h5 class='topic'>Activation (opt-in)</h5>
+ * <p>
+ * This processor ships in the opt-in {@code juneau-rest-server-reactive} 
module and is <b>not</b> wired
+ * into {@code DefaultConfig}. A bare {@code juneau-rest-server} has zero 
reactive behavior. When the
+ * {@code juneau-rest-server-reactive} jar is on the classpath, its
+ * {@code 
META-INF/services/org.apache.juneau.rest.processor.ResponseProcessor} provider 
file is discovered
+ * by {@code RestContext} via {@link java.util.ServiceLoader} and this 
processor is front-loaded ahead of
+ * {@code AsyncResponseProcessor} in the chain &mdash; no {@code 
@Rest(responseProcessors=...)} entry required.
+ *
+ * <h5 class='topic'>What it handles</h5>
+ * <p>
+ * This is the single, shared spine for all reactive return-type support. It 
natively understands the
+ * JDK type {@link java.util.concurrent.Flow.Publisher 
Flow.Publisher&lt;T&gt;} (no external dependency
+ * required) and, through registered {@link ReactiveStreamsAdapter} providers, 
any third-party reactive
+ * type the opt-in {@code juneau-rest-server-reactor} module adapts to it 
(Project Reactor
+ * {@code Mono} / {@code Flux}, RxJava 3 {@code Single} / {@code Maybe} / 
{@code Flowable} /
+ * {@code Observable}, and the Reactive-Streams {@code 
org.reactivestreams.Publisher}).
+ *
+ * <p>
+ * Single-value reactive types (e.g. {@code Mono}, {@code Single}) are adapted 
to a {@link CompletionStage}
+ * and collapse onto the existing {@link AsyncResponseProcessor} async path, 
inheriting its timeout,
+ * completion-executor ({@code @Rest(asyncCompletionExecutor)}), and MDC 
bridging behavior for free.
+ *
+ * <h5 class='topic'>Response shapes for multi-value streams</h5>
+ * <p>
+ * A streaming publisher is rendered as one of three shapes, selected by the 
negotiated response media
+ * type (the handler's {@link RestResponse#setContentType(String) 
Content-Type}, then the request
+ * {@code Accept} header):
+ * <ul>
+ *     <li><b>SSE</b> ({@code text/event-stream}) &mdash; each element is 
emitted as a Server-Sent-Events
+ *             frame. {@link SseEvent} elements are written verbatim; any 
other element type is JSON-encoded
+ *             into the {@code data:} field.
+ *     <li><b>NDJSON</b> ({@code application/x-ndjson}, {@code 
application/jsonl}) &mdash; each element is
+ *             JSON-encoded on its own line.
+ *     <li><b>Buffer</b> (default, any other media type) &mdash; all elements 
are collected into a
+ *             {@link java.util.List List} and serialized through the normal 
serializer chain (e.g. a JSON
+ *             array). The collection is wrapped in a {@link 
CompletableFuture} and handed to the async path,
+ *             so a slow producer never blocks the request thread.
+ * </ul>
+ *
+ * <h5 class='topic'>Backpressure</h5>
+ * <p>
+ * Streaming subscribers request one element at a time ({@code request(1)} on 
subscribe and again after
+ * each frame is written and flushed). Because writing to the servlet output 
stream blocks until the
+ * socket accepts the bytes, this gives natural backpressure &mdash; the 
producer is paced by the
+ * client's drain rate and the server-side buffer does not grow without bound. 
Buffer-shape subscribers
+ * request {@link Long#MAX_VALUE} since the collection is bounded by the 
publisher's own completion.
+ *
+ * <h5 class='topic'>Threading, executors, and MDC</h5>
+ * <p>
+ * Buffer-shape responses route through {@link AsyncResponseProcessor} and 
therefore honor
+ * {@code @Rest(asyncCompletionExecutor)} (TODO-118) and the SLF4J MDC bridge 
(TODO-117). Streaming-shape
+ * frame writes happen on whichever thread the publisher emits on (the Reactor 
/ RxJava scheduler); this
+ * processor does not impose a {@code subscribeOn(...)} so it never fights the 
library's scheduler model.
+ * When MDC propagation is enabled, the request-thread MDC snapshot is 
reinstalled around each
+ * {@code onNext} / terminal callback via {@link MdcAsyncListener} so log 
statements emitted while writing
+ * a frame see the request's diagnostic context.
+ *
+ * <h5 class='topic'>Synchronous fallback</h5>
+ * <p>
+ * In environments where {@link HttpServletRequest#startAsync()} is 
unsupported (notably Juneau's
+ * {@code MockServletRequest}), streaming subscribes synchronously and blocks 
the request thread until
+ * the publisher terminates (bounded by the configured async timeout), writing 
frames as they arrive.
+ * This keeps the unit-test surface working without a real servlet container.
+ *
+ * @see ReactiveStreamsAdapter
+ * @see AsyncResponseProcessor
+ * @since 9.5.0
+ */
+public class ReactiveResponseProcessor implements ResponseProcessor {
+
+       private static final Logger LOG = 
Logger.getLogger(ReactiveResponseProcessor.class.getName());
+
+       private static volatile List<ReactiveStreamsAdapter> adapters;
+
+       private enum Shape { BUFFER, SSE, NDJSON }
+
+       @FunctionalInterface
+       private interface FrameEncoder {
+               void write(FinishablePrintWriter w, Object element) throws 
IOException, SerializeException;
+       }
+
+       @Override /* Overridden from ResponseProcessor */
+       public int process(RestOpSession opSession) throws IOException, 
BasicHttpException {
+               var res = opSession.getResponse();
+               var content = res.getContent().orElse(null);
+
+               if (content == null)
+                       return NEXT;
+
+               if (content instanceof Flow.Publisher<?> pub)
+                       return dispatch(opSession, pub);
+
+               var a = adapters();
+               for (var adapter : a) {
+                       if (adapter.canAdapt(content)) {
+                               var adaptation = adapter.adapt(content);
+                               if (adaptation.isStream())
+                                       return dispatch(opSession, 
adaptation.stream());
+                               res.setContent(adaptation.single());
+                               return RESTART;
+                       }
+               }
+
+               return NEXT;
+       }
+
+       private int dispatch(RestOpSession opSession, Flow.Publisher<?> pub) 
throws IOException {
+               var shape = resolveShape(opSession);
+               if (shape == Shape.BUFFER)
+                       return handleBuffer(opSession, pub);
+               return handleStream(opSession, pub, shape);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Buffer shape — collect into a List, hand to the async path.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private int handleBuffer(RestOpSession opSession, Flow.Publisher<?> 
pub) {
+               var cf = new CompletableFuture<List<Object>>();
+               pub.subscribe(new CollectingSubscriber(cf));
+               opSession.getResponse().setContent(cf);
+               return RESTART;
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Streaming shapes — SSE / NDJSON.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @SuppressWarnings({
+               "java:S3776", // Streaming setup is branchy by nature 
(content-type prep + async-vs-sync fallback).
+               "java:S1141"  // Nested try/catch cleanly separates startAsync 
recovery from the sync fallback.
+       })
+       private int handleStream(RestOpSession opSession, Flow.Publisher<?> 
pub, Shape shape) throws IOException {
+               var res = opSession.getResponse();
+               prepareStreamingHeaders(res, shape);
+
+               var writer = res.getNegotiatedWriter();
+               FrameEncoder encoder = shape == Shape.SSE
+                       ? ReactiveResponseProcessor::writeSseFrame
+                       : ReactiveResponseProcessor::writeNdjsonFrame;
+
+               var req = opSession.getRequest().getHttpServletRequest();
+               var mdc = opSession.getRestContext().isMdcAsyncPropagation()
+                       ? MdcAsyncListener.snapshot()
+                       : null;
+
+               AsyncContext asyncCtx = null;
+               if (req.isAsyncSupported()) {
+                       try {
+                               asyncCtx = req.startAsync();
+                       } catch (IllegalStateException e) {
+                               asyncCtx = null;  // Already committed, or 
async not actually supported.
+                       }
+               }
+
+               if (asyncCtx != null) {
+                       
req.setAttribute(AsyncResponseProcessor.ATTR_ASYNC_DISPATCH_OWNED, 
Boolean.TRUE);
+                       var ac = asyncCtx;
+                       ac.setTimeout(0);  // No artificial timeout — streams 
are paced by the producer / client disconnect.
+                       pub.subscribe(new StreamingSubscriber(res, writer, 
encoder, mdc, t -> completeAsync(ac, res, t)));
+                       return FINISHED;
+               }
+
+               // Synchronous fallback (MockServletRequest et al.): block 
until the publisher terminates.
+               var done = new CompletableFuture<Void>();
+               pub.subscribe(new StreamingSubscriber(res, writer, encoder, mdc,
+                       t -> { if (t == null) done.complete(null); else 
done.completeExceptionally(t); }));
+               awaitSync(done, syncTimeoutMillis(opSession));
+               try {
+                       writer.flush();
+                       res.flushBuffer();
+               } catch (IOException e) {
+                       LOG.log(Level.FINEST, e, () -> "Final flush of 
synchronous reactive stream failed: " + e.getMessage());
+               }
+               return FINISHED;
+       }
+
+       private static void prepareStreamingHeaders(RestResponse res, Shape 
shape) {
+               var ct = res.getContentType();
+               if (shape == Shape.SSE) {
+                       if (ct == null || ! ct.contains("event-stream"))
+                               res.setContentType(SseSerializer.MEDIA_TYPE);
+                       res.setHeader("X-Content-Type-Options", "nosniff");
+               } else if (ct == null) {
+                       res.setContentType("application/x-ndjson");
+               }
+               res.setHeader("Cache-Control", "no-cache");
+               res.setHeader("Content-Encoding", "identity");
+       }
+
+       @SuppressWarnings("java:S2142") // Interrupt flag restored before 
returning.
+       private static void awaitSync(CompletableFuture<Void> done, long 
timeoutMs) {
+               try {
+                       done.get(timeoutMs, TimeUnit.MILLISECONDS);
+               } catch (InterruptedException e) {
+                       Thread.currentThread().interrupt();
+               } catch (TimeoutException | ExecutionException e) {
+                       LOG.log(Level.FINE, e, () -> "Synchronous reactive 
stream did not complete cleanly: " + e.getMessage());
+               }
+       }
+
+       private static void completeAsync(AsyncContext asyncCtx, RestResponse 
res, Throwable error) {
+               try {
+                       if (error != null)
+                               LOG.log(Level.FINE, error, () -> "Reactive 
stream terminated with error: " + error.getMessage());
+                       if (! res.getHttpServletResponse().isCommitted())
+                               res.flushBuffer();
+               } catch (IOException e) {
+                       LOG.log(Level.FINEST, e, () -> "Flush during async 
stream completion failed: " + e.getMessage());
+               } finally {
+                       try {
+                               asyncCtx.complete();
+                       } catch (IllegalStateException e) {
+                               LOG.log(Level.FINEST, e, () -> 
"AsyncContext.complete() raced with the container: " + e.getMessage());
+                       }
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Frame encoders.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @SuppressWarnings({ "java:S2095", "resource" }) // Writer is 
response-owned; we never close it.
+       private static void writeSseFrame(FinishablePrintWriter w, Object 
element) throws IOException, SerializeException {
+               if (element == null)
+                       return;
+               if (element instanceof SseEvent ev) {
+                       SseSerializer.DEFAULT.serialize(ev, w);
+                       return;
+               }
+               var data = element instanceof CharSequence c ? c.toString() : 
Json.of(element);
+               SseSerializer.DEFAULT.serialize(new SseEvent(null, data), w);
+       }
+
+       @SuppressWarnings({ "java:S2095", "resource" }) // Writer is 
response-owned; we never close it.
+       private static void writeNdjsonFrame(FinishablePrintWriter w, Object 
element) throws IOException, SerializeException {
+               if (element == null)
+                       return;
+               var json = element instanceof CharSequence c ? c.toString() : 
Json.of(element);
+               w.write(json);
+               w.write("\n");
+               w.flush();
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Shape / timeout resolution.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static Shape resolveShape(RestOpSession opSession) {
+               var res = opSession.getResponse();
+               var ct = res.getContentType();
+               var accept = 
opSession.getRequest().getHttpServletRequest().getHeader("Accept");
+               var probe = ((ct == null ? "" : ct) + "," + (accept == null ? 
"" : accept)).toLowerCase(Locale.ROOT);
+               if (probe.contains("event-stream"))
+                       return Shape.SSE;
+               if (probe.contains("ndjson") || probe.contains("jsonl") || 
probe.contains("json-seq"))
+                       return Shape.NDJSON;
+               return Shape.BUFFER;
+       }
+
+       private static long syncTimeoutMillis(RestOpSession opSession) {
+               var t = opSession.getContext().getAsyncTimeoutMillis();
+               if (t <= 0)
+                       t = opSession.getRestContext().getAsyncTimeoutMillis();
+               return t > 0 ? t : 
AsyncResponseProcessor.DEFAULT_ASYNC_TIMEOUT_MILLIS;
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Adapter discovery (ServiceLoader, cached, defensive against absent 
backing libraries).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static List<ReactiveStreamsAdapter> adapters() {
+               var a = adapters;
+               if (a == null) {
+                       synchronized (ReactiveResponseProcessor.class) {
+                               a = adapters;
+                               if (a == null) {
+                                       a = loadAdapters();
+                                       adapters = a;
+                               }
+                       }
+               }
+               return a;
+       }
+
+       private static List<ReactiveStreamsAdapter> loadAdapters() {
+               var out = new ArrayList<ReactiveStreamsAdapter>();
+               try {
+                       var it = 
ServiceLoader.load(ReactiveStreamsAdapter.class).iterator();
+                       while (it.hasNext()) {
+                               try {
+                                       out.add(it.next());
+                               } catch (ServiceConfigurationError | 
RuntimeException e) {
+                                       LOG.log(Level.FINE, e, () -> "Skipping 
reactive adapter (backing library likely absent): " + e.getMessage());
+                               }
+                       }
+               } catch (ServiceConfigurationError | RuntimeException e) {
+                       LOG.log(Level.FINE, e, () -> "ServiceLoader for 
ReactiveStreamsAdapter failed: " + e.getMessage());
+               }
+               return List.copyOf(out);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Subscribers.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       /** Collects all emitted elements into a list and completes the 
supplied future (buffer shape). */
+       private static final class CollectingSubscriber implements 
Flow.Subscriber<Object> {
+               private final CompletableFuture<List<Object>> cf;
+               private final List<Object> items = new ArrayList<>();
+
+               CollectingSubscriber(CompletableFuture<List<Object>> cf) {
+                       this.cf = cf;
+               }
+
+               @Override public void onSubscribe(Flow.Subscription s) { 
s.request(Long.MAX_VALUE); }
+               @Override public void onNext(Object item) { items.add(item); }
+               @Override public void onError(Throwable t) { 
cf.completeExceptionally(t); }
+               @Override public void onComplete() { cf.complete(items); }
+       }
+
+       /** Writes each emitted element as a wire frame (SSE / NDJSON), one 
element at a time (bounded backpressure). */
+       private static final class StreamingSubscriber implements 
Flow.Subscriber<Object> {
+               private final RestResponse res;
+               private final FinishablePrintWriter writer;
+               private final FrameEncoder encoder;
+               private final Map<String,String> mdc;
+               private final Consumer<Throwable> onTerminate;
+               private final AtomicBoolean terminated = new AtomicBoolean();
+               private volatile boolean wrote;
+               private Flow.Subscription subscription;
+
+               StreamingSubscriber(RestResponse res, FinishablePrintWriter 
writer, FrameEncoder encoder,
+                               Map<String,String> mdc, Consumer<Throwable> 
onTerminate) {
+                       this.res = res;
+                       this.writer = writer;
+                       this.encoder = encoder;
+                       this.mdc = mdc;
+                       this.onTerminate = onTerminate;
+               }
+
+               @Override public void onSubscribe(Flow.Subscription s) {
+                       subscription = s;
+                       s.request(1);
+               }
+
+               @Override public void onNext(Object item) {
+                       try {
+                               withMdc(() -> writeFrame(item));
+                               subscription.request(1);
+                       } catch (RuntimeException e) {
+                               subscription.cancel();
+                               terminate(unwrap(e));
+                       }
+               }
+
+               @Override public void onError(Throwable t) { terminate(t); }
+               @Override public void onComplete() { terminate(null); }
+
+               private void writeFrame(Object item) {
+                       try {
+                               encoder.write(writer, item);
+                               writer.flush();
+                               res.flushBuffer();
+                               wrote = true;
+                       } catch (IOException | SerializeException e) {
+                               throw new IllegalStateException(e);
+                       }
+               }
+
+               @SuppressWarnings("unchecked")
+               private void withMdc(Runnable r) {
+                       MdcAsyncListener.wrap((BiConsumer<Object,Throwable>) 
(v, e) -> r.run(), mdc).accept(null, null);
+               }
+
+               private void terminate(Throwable t) {
+                       if (! terminated.compareAndSet(false, true))
+                               return;
+                       withMdc(() -> {
+                               if (t != null && ! wrote) {
+                                       try {
+                                               if (! 
res.getHttpServletResponse().isCommitted())
+                                                       
res.getHttpServletResponse().sendError(SC_INTERNAL_SERVER_ERROR);
+                                       } catch (IOException e) {
+                                               LOG.log(Level.FINEST, e, () -> 
"sendError on pre-write stream failure failed: " + e.getMessage());
+                                       }
+                               }
+                       });
+                       onTerminate.accept(t);
+               }
+       }
+
+       private static Throwable unwrap(Throwable t) {
+               if ((t instanceof IllegalStateException || t instanceof 
CompletionException) && t.getCause() != null)
+                       return t.getCause();
+               return t;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/ReactiveStreamsAdapter.java
 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/ReactiveStreamsAdapter.java
new file mode 100644
index 0000000000..d46661961d
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/ReactiveStreamsAdapter.java
@@ -0,0 +1,78 @@
+/*
+ * 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.juneau.rest.reactive;
+
+import java.util.concurrent.*;
+
+/**
+ * SPI that bridges a third-party reactive return type to one of the two 
JDK-native shapes that the
+ * {@link ReactiveResponseProcessor} understands: a {@link CompletionStage} 
(single value) or a
+ * {@link java.util.concurrent.Flow.Publisher Flow.Publisher} (a stream of 
values).
+ *
+ * <h5 class='topic'>Why this exists</h5>
+ * <p>
+ * The opt-in {@code juneau-rest-server-reactive} module handles {@link 
java.util.concurrent.Flow.Publisher Flow.Publisher}
+ * (and, via the existing {@code AsyncResponseProcessor}, {@link 
CompletionStage}) using only JDK APIs &mdash;
+ * no external dependency. Libraries such as Project Reactor ({@code Mono} / 
{@code Flux}), RxJava 3
+ * ({@code Single} / {@code Maybe} / {@code Flowable} / {@code Observable}), 
and the
+ * Reactive-Streams {@code org.reactivestreams.Publisher} are NOT on the core 
classpath. The opt-in
+ * {@code juneau-rest-server-reactor} module ships {@code 
ReactiveStreamsAdapter} implementations that
+ * convert those types into the JDK-native shapes, so the streaming / 
buffering / SSE / NDJSON plumbing
+ * is implemented exactly once in the core.
+ *
+ * <h5 class='topic'>Registration</h5>
+ * <p>
+ * Adapters are discovered via the JDK {@link java.util.ServiceLoader} 
mechanism. A bridge module (or a
+ * consumer who wants a custom adapter) ships a
+ * {@code 
META-INF/services/org.apache.juneau.rest.reactive.ReactiveStreamsAdapter} file 
listing the
+ * implementation class names. The {@link ReactiveResponseProcessor} loads the 
providers once (lazily,
+ * at first use) and skips any provider whose backing library is absent from 
the runtime classpath
+ * (a {@link NoClassDefFoundError} / {@link ServiceConfigurationError} on 
instantiation is swallowed).
+ * This means a single {@code juneau-rest-server-reactor} jar can declare 
Reactor, RxJava, and
+ * Reactive-Streams adapters while the consumer pulls only the {@code 
provided}-scope libraries they
+ * actually want.
+ *
+ * @see ReactiveResponseProcessor
+ * @see Adaptation
+ * @since 9.5.0
+ */
+public interface ReactiveStreamsAdapter {
+
+       /**
+        * Returns whether this adapter recognizes the supplied return value.
+        *
+        * <p>
+        * Implementations must return {@code false} (rather than throw) for 
values they do not handle, and
+        * must be cheap to call &mdash; this is invoked on the response hot 
path for every non-null handler
+        * return value once at least one adapter is registered.
+        *
+        * @param value The {@code @RestOp} handler return value. Never {@code 
null}.
+        * @return {@code true} if {@link #adapt(Object)} can convert this 
value.
+        */
+       boolean canAdapt(Object value);
+
+       /**
+        * Converts the supplied reactive value to a JDK-native {@link 
Adaptation}.
+        *
+        * <p>
+        * Only called when {@link #canAdapt(Object)} returned {@code true} for 
the same value.
+        *
+        * @param value The reactive value to adapt. Never {@code null}.
+        * @return The adapted single-value or streaming shape. Never {@code 
null}.
+        */
+       Adaptation adapt(Object value);
+}
diff --git 
a/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/package-info.java
 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/package-info.java
new file mode 100644
index 0000000000..1a37b4fd15
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactive/src/main/java/org/apache/juneau/rest/reactive/package-info.java
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+/**
+ * Reactive-Streams return-type support for {@code @RestOp} handlers (opt-in, 
dependency-free).
+ *
+ * <p>
+ * This package is the JDK-native spine that lets {@code @RestOp} handlers 
return reactive values. It
+ * ships in the opt-in {@code juneau-rest-server-reactive} module &mdash; a 
bare {@code juneau-rest-server}
+ * has <b>no</b> reactive behavior and {@code DefaultConfig} does not wire any 
reactive processor. Merely
+ * placing this module on the classpath auto-registers {@link 
org.apache.juneau.rest.reactive.ReactiveResponseProcessor}
+ * via a {@code 
META-INF/services/org.apache.juneau.rest.processor.ResponseProcessor} provider 
file, which
+ * {@code RestContext} discovers through {@link java.util.ServiceLoader} and 
front-loads ahead of
+ * {@code AsyncResponseProcessor}.
+ *
+ * <p>
+ * The processor handles {@link java.util.concurrent.Flow.Publisher 
Flow.Publisher&lt;T&gt;} directly (no
+ * external dependency) and exposes the {@link 
org.apache.juneau.rest.reactive.ReactiveStreamsAdapter} SPI
+ * so the further opt-in {@code juneau-rest-server-reactor} module can plug in 
Project Reactor, RxJava 3,
+ * and Reactive-Streams {@code Publisher} support without duplicating any of 
the streaming / buffering /
+ * SSE / NDJSON plumbing.
+ *
+ * <h5 class='topic'>Key types</h5>
+ * <ul>
+ *     <li>{@link org.apache.juneau.rest.reactive.ReactiveResponseProcessor} 
&mdash; the response processor
+ *             (auto-registered via {@code ServiceLoader}, front-loaded ahead 
of {@code AsyncResponseProcessor}).
+ *     <li>{@link org.apache.juneau.rest.reactive.ReactiveStreamsAdapter} 
&mdash; the {@link java.util.ServiceLoader}
+ *             SPI bridge modules implement.
+ *     <li>{@link org.apache.juneau.rest.reactive.Adaptation} &mdash; the 
single-value / streaming result of an adapter.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerReactive";>REST Server 
&mdash; Reactive Streams</a>
+ *     <li class='link'><a class="doclink" 
href="https://www.reactive-streams.org/";>Reactive Streams</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.reactive;
diff --git 
a/juneau-rest/juneau-rest-server-reactive/src/main/resources/META-INF/services/org.apache.juneau.rest.processor.ResponseProcessor
 
b/juneau-rest/juneau-rest-server-reactive/src/main/resources/META-INF/services/org.apache.juneau.rest.processor.ResponseProcessor
new file mode 100644
index 0000000000..b981efc2f3
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactive/src/main/resources/META-INF/services/org.apache.juneau.rest.processor.ResponseProcessor
@@ -0,0 +1 @@
+org.apache.juneau.rest.reactive.ReactiveResponseProcessor
diff --git a/juneau-rest/juneau-rest-server-reactor/pom.xml 
b/juneau-rest/juneau-rest-server-reactor/pom.xml
new file mode 100644
index 0000000000..3163152b3a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-reactor/pom.xml
@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
https://maven.apache.org/xsd/maven-4.0.0.xsd";>
+
+       <modelVersion>4.0.0</modelVersion>
+
+       <parent>
+               <groupId>org.apache.juneau</groupId>
+               <artifactId>juneau-rest</artifactId>
+               <version>9.5.0-SNAPSHOT</version>
+       </parent>
+
+       <artifactId>juneau-rest-server-reactor</artifactId>
+       <name>Apache Juneau REST Server Reactive Streams</name>
+       <description>Apache Juneau REST Server - Reactor / RxJava 3 / 
Reactive-Streams Publisher return-type bridge (opt-in, 
provided-scope)</description>
+       <packaging>bundle</packaging>
+
+       <properties>
+               
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+               <reactive-streams.version>1.0.4</reactive-streams.version>
+               <reactor.version>3.6.11</reactor.version>
+               <rxjava.version>3.1.10</rxjava.version>
+       </properties>
+
+       <dependencies>
+               <!--
+                       Depends on the dependency-free 
juneau-rest-server-reactive module (which transitively brings
+                       juneau-rest-server). That module carries the shared 
ReactiveResponseProcessor spine + the
+                       ReactiveStreamsAdapter SPI + the auto-registration 
service file; this module contributes ONLY
+                       the third-party Reactor / RxJava / Reactive-Streams 
adapters on top of it.
+               -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-reactive</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+
+               <!--
+                       Reactor / RxJava / Reactive-Streams are ALL declared 
`provided` so none of them leak as a
+                       transitive dependency of this module OR of any module 
that depends on it.  Each adapter is
+                       discovered lazily via ServiceLoader and is skipped at 
runtime if its backing library is
+                       absent from the classpath, so a consumer adds 
`juneau-rest-server-reactor` plus ONLY the
+                       reactive library (and version) they actually use.
+
+                       Containment guarantee: a `dependency:tree` on 
juneau-rest-server (the upstream module) must
+                       never surface reactor / rxjava / reactive-streams.
+               -->
+               <dependency>
+                       <groupId>org.reactivestreams</groupId>
+                       <artifactId>reactive-streams</artifactId>
+                       <version>${reactive-streams.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.projectreactor</groupId>
+                       <artifactId>reactor-core</artifactId>
+                       <version>${reactor.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.reactivex.rxjava3</groupId>
+                       <artifactId>rxjava</artifactId>
+                       <version>${rxjava.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+       </dependencies>
+
+       <build>
+               <plugins>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-source-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>attach-sources</id>
+                                               <phase>verify</phase>
+                                               <goals>
+                                                       <goal>jar-no-fork</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.felix</groupId>
+                               <artifactId>maven-bundle-plugin</artifactId>
+                               <extensions>true</extensions>
+                               <configuration>
+                                       
<supportIncrementalBuild>true</supportIncrementalBuild>
+                               </configuration>
+                               <executions>
+                                       <execution>
+                                               <id>bundle-manifest</id>
+                                               <phase>process-classes</phase>
+                                               <goals>
+                                                       <goal>manifest</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-jar-plugin</artifactId>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.jacoco</groupId>
+                               <artifactId>jacoco-maven-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>default-prepare-agent</id>
+                                               <goals>
+                                                       
<goal>prepare-agent</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-report</id>
+                                               <phase>prepare-package</phase>
+                                               <goals>
+                                                       <goal>report</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+               </plugins>
+       </build>
+</project>
diff --git 
a/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/ReactiveStreamsPublisherAdapter.java
 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/ReactiveStreamsPublisherAdapter.java
new file mode 100644
index 0000000000..f6ff8f3cfa
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/ReactiveStreamsPublisherAdapter.java
@@ -0,0 +1,47 @@
+/*
+ * 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.juneau.rest.reactive.bridge;
+
+import org.apache.juneau.rest.reactive.*;
+import org.reactivestreams.*;
+
+/**
+ * Catch-all {@link ReactiveStreamsAdapter} for any
+ * <a class="doclink" 
href="https://www.reactive-streams.org/";>Reactive-Streams</a>
+ * {@link org.reactivestreams.Publisher 
org.reactivestreams.Publisher&lt;T&gt;} that is not already
+ * handled by a more specific adapter.
+ *
+ * <p>
+ * Converts to a JDK {@link java.util.concurrent.Flow.Publisher 
Flow.Publisher} via
+ * {@link org.reactivestreams.FlowAdapters#toFlowPublisher(Publisher)} and 
renders it as a stream.
+ * Registered <em>last</em> so library-specific single-value types (Reactor 
{@code Mono}, which also
+ * implements {@code Publisher}) are matched by their own adapter first.
+ *
+ * @since 9.5.0
+ */
+public class ReactiveStreamsPublisherAdapter implements ReactiveStreamsAdapter 
{
+
+       @Override /* Overridden from ReactiveStreamsAdapter */
+       public boolean canAdapt(Object value) {
+               return value instanceof Publisher;
+       }
+
+       @Override /* Overridden from ReactiveStreamsAdapter */
+       public Adaptation adapt(Object value) {
+               return 
Adaptation.stream(FlowAdapters.toFlowPublisher((Publisher<?>) value));
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/ReactorReactiveAdapter.java
 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/ReactorReactiveAdapter.java
new file mode 100644
index 0000000000..2174b7516a
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/ReactorReactiveAdapter.java
@@ -0,0 +1,54 @@
+/*
+ * 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.juneau.rest.reactive.bridge;
+
+import org.apache.juneau.rest.reactive.*;
+import org.reactivestreams.*;
+
+import reactor.core.publisher.*;
+
+/**
+ * {@link ReactiveStreamsAdapter} for <a class="doclink" 
href="https://projectreactor.io/";>Project Reactor</a>
+ * return types.
+ *
+ * <ul>
+ *     <li>{@link Mono Mono&lt;T&gt;} &rarr; single value (via {@link 
Mono#toFuture()}). An empty {@code Mono}
+ *             completes the response with a {@code null} body.
+ *     <li>{@link Flux Flux&lt;T&gt;} &rarr; stream (via {@link 
org.reactivestreams.FlowAdapters#toFlowPublisher(Publisher)}).
+ * </ul>
+ *
+ * <p>
+ * Registered ahead of {@link ReactiveStreamsPublisherAdapter} so a {@code 
Mono} (which also implements
+ * {@code org.reactivestreams.Publisher}) is treated as a single value rather 
than a one-element stream.
+ *
+ * @since 9.5.0
+ */
+public class ReactorReactiveAdapter implements ReactiveStreamsAdapter {
+
+       @Override /* Overridden from ReactiveStreamsAdapter */
+       public boolean canAdapt(Object value) {
+               return value instanceof Mono || value instanceof Flux;
+       }
+
+       @Override /* Overridden from ReactiveStreamsAdapter */
+       public Adaptation adapt(Object value) {
+               if (value instanceof Mono<?> m)
+                       return Adaptation.single(m.toFuture());
+               var f = (Flux<?>) value;
+               return Adaptation.stream(FlowAdapters.toFlowPublisher(f));
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/RxJavaReactiveAdapter.java
 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/RxJavaReactiveAdapter.java
new file mode 100644
index 0000000000..e7c2b8ed53
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/RxJavaReactiveAdapter.java
@@ -0,0 +1,64 @@
+/*
+ * 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.juneau.rest.reactive.bridge;
+
+import org.apache.juneau.rest.reactive.*;
+import org.reactivestreams.*;
+
+import io.reactivex.rxjava3.core.*;
+
+/**
+ * {@link ReactiveStreamsAdapter} for <a class="doclink" 
href="https://github.com/ReactiveX/RxJava";>RxJava 3</a>
+ * return types.
+ *
+ * <ul>
+ *     <li>{@link Single Single&lt;T&gt;} &rarr; single value (via {@link 
Single#toCompletionStage()}).
+ *     <li>{@link Maybe Maybe&lt;T&gt;} &rarr; single value (via {@link 
Maybe#toCompletionStage(Object)} with a
+ *             {@code null} default, so an empty {@code Maybe} yields a {@code 
null} body).
+ *     <li>{@link Completable} &rarr; single {@code null} value (via {@link 
Completable#toCompletionStage(Object)}).
+ *     <li>{@link Flowable Flowable&lt;T&gt;} &rarr; stream (via {@link 
org.reactivestreams.FlowAdapters#toFlowPublisher(Publisher)}).
+ *     <li>{@link Observable Observable&lt;T&gt;} &rarr; stream (converted to 
a {@link Flowable} with
+ *             {@link BackpressureStrategy#BUFFER} first, since {@code 
Observable} has no native backpressure).
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class RxJavaReactiveAdapter implements ReactiveStreamsAdapter {
+
+       @Override /* Overridden from ReactiveStreamsAdapter */
+       public boolean canAdapt(Object value) {
+               return value instanceof Single
+                       || value instanceof Maybe
+                       || value instanceof Completable
+                       || value instanceof Flowable
+                       || value instanceof Observable;
+       }
+
+       @Override /* Overridden from ReactiveStreamsAdapter */
+       public Adaptation adapt(Object value) {
+               if (value instanceof Single<?> s)
+                       return Adaptation.single(s.toCompletionStage());
+               if (value instanceof Maybe<?> m)
+                       return Adaptation.single(m.toCompletionStage(null));
+               if (value instanceof Completable c)
+                       return Adaptation.single(c.toCompletionStage(null));
+               if (value instanceof Flowable<?> f)
+                       return 
Adaptation.stream(FlowAdapters.toFlowPublisher(f));
+               var o = (Observable<?>) value;
+               return 
Adaptation.stream(FlowAdapters.toFlowPublisher(o.toFlowable(BackpressureStrategy.BUFFER)));
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/package-info.java
 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/package-info.java
new file mode 100644
index 0000000000..6ff5b1d76c
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactor/src/main/java/org/apache/juneau/rest/reactive/bridge/package-info.java
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+/**
+ * Project Reactor / RxJava 3 / Reactive-Streams {@code Publisher} adapters 
for the
+ * {@code juneau-rest-server} {@link 
org.apache.juneau.rest.reactive.ReactiveStreamsAdapter} SPI.
+ *
+ * <p>
+ * This module is opt-in. It ships three {@link 
org.apache.juneau.rest.reactive.ReactiveStreamsAdapter}
+ * implementations &mdash; {@link 
org.apache.juneau.rest.reactive.bridge.ReactorReactiveAdapter},
+ * {@link org.apache.juneau.rest.reactive.bridge.RxJavaReactiveAdapter}, and
+ * {@link 
org.apache.juneau.rest.reactive.bridge.ReactiveStreamsPublisherAdapter} &mdash; 
registered via a
+ * {@code META-INF/services} file so merely placing this jar (plus the desired 
reactive library) on the
+ * classpath enables {@code Mono} / {@code Flux} / {@code Single} / {@code 
Maybe} / {@code Flowable} /
+ * {@code Observable} / {@code Publisher} return types from {@code @RestOp} 
handlers.
+ *
+ * <h5 class='topic'>Containment</h5>
+ * <p>
+ * The {@code reactor-core}, {@code rxjava}, and {@code reactive-streams} 
dependencies are declared in
+ * {@code provided} scope on this module's POM. Consumers add only the 
reactive library and version they
+ * actually use; adapters whose backing library is absent are skipped at 
{@link java.util.ServiceLoader}
+ * load time. The core {@code juneau-rest-server} jar stays dependency-free.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.reactive.ReactiveResponseProcessor}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerReactive";>REST Server 
&mdash; Reactive Streams</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.reactive.bridge;
diff --git 
a/juneau-rest/juneau-rest-server-reactor/src/main/resources/META-INF/services/org.apache.juneau.rest.reactive.ReactiveStreamsAdapter
 
b/juneau-rest/juneau-rest-server-reactor/src/main/resources/META-INF/services/org.apache.juneau.rest.reactive.ReactiveStreamsAdapter
new file mode 100644
index 0000000000..8ff6208546
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-reactor/src/main/resources/META-INF/services/org.apache.juneau.rest.reactive.ReactiveStreamsAdapter
@@ -0,0 +1,3 @@
+org.apache.juneau.rest.reactive.bridge.ReactorReactiveAdapter
+org.apache.juneau.rest.reactive.bridge.RxJavaReactiveAdapter
+org.apache.juneau.rest.reactive.bridge.ReactiveStreamsPublisherAdapter
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index acebf12b8d..ac854c12c5 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -1608,6 +1608,59 @@ public class RestContext extends Context {
                return u(toList(s));
        });
 
+       /**
+        * JVM-wide cache of {@link ResponseProcessor} provider classes 
discovered via {@link ServiceLoader}.
+        *
+        * <p>
+        * Populated lazily on first use and shared across all {@link 
RestContext} instances. Empty on a bare
+        * {@code juneau-rest-server} classpath; non-empty only when an opt-in 
module (e.g.
+        * {@code juneau-rest-server-reactive}) ships a
+        * {@code 
META-INF/services/org.apache.juneau.rest.processor.ResponseProcessor} provider 
file.
+        */
+       private static volatile List<Class<? extends ResponseProcessor>> 
serviceLoaderResponseProcessors;
+
+       /**
+        * Returns the module-contributed {@link ResponseProcessor} classes 
discovered via {@link ServiceLoader},
+        * caching the result for the lifetime of the JVM.
+        *
+        * <p>
+        * Provider <em>types</em> are resolved (not instantiated) so each 
{@link RestContext} can instantiate its
+        * own bean-store-injected copy. Discovery failures are swallowed at 
{@code FINE} so a malformed provider
+        * never fails resource startup; on a bare classpath the returned list 
is empty and the default processor
+        * chain is unchanged.
+        *
+        * @return The discovered processor classes (possibly empty, never 
{@code null}).
+        */
+       private static List<Class<? extends ResponseProcessor>> 
discoverServiceLoaderResponseProcessors() {
+               var p = serviceLoaderResponseProcessors;
+               if (p == null) {
+                       synchronized (RestContext.class) {
+                               p = serviceLoaderResponseProcessors;
+                               if (p == null) {
+                                       p = 
loadServiceLoaderResponseProcessors();
+                                       serviceLoaderResponseProcessors = p;
+                               }
+                       }
+               }
+               return p;
+       }
+
+       private static List<Class<? extends ResponseProcessor>> 
loadServiceLoaderResponseProcessors() {
+               var out = new ArrayList<Class<? extends ResponseProcessor>>();
+               try {
+                       for (var it = 
ServiceLoader.load(ResponseProcessor.class).stream().iterator(); it.hasNext();) 
{
+                               try {
+                                       out.add(it.next().type());
+                               } catch (ServiceConfigurationError | 
RuntimeException e) {
+                                       LOG.log(Level.FINE, e, () -> "Skipping 
ServiceLoader-discovered ResponseProcessor: " + e.getMessage());
+                               }
+                       }
+               } catch (ServiceConfigurationError | RuntimeException e) {
+                       LOG.log(Level.FINE, e, () -> "ServiceLoader for 
ResponseProcessor failed: " + e.getMessage());
+               }
+               return List.copyOf(out);
+       }
+
        /**
         * The ordered array of {@link ResponseProcessor} instances for this 
resource.
         *
@@ -1626,6 +1679,12 @@ public class RestContext extends Context {
                // at request time when no TracerHook stashed a trace context, 
so it's zero-cost on the no-tracer path.
                if (defaultResponseTraceparent)
                        b.add(TraceContextResponseProcessor.class);
+               // TODO-119/120 refactor: front-load any module-contributed 
ResponseProcessors discovered via
+               // ServiceLoader (e.g. the opt-in juneau-rest-server-reactive 
module's ReactiveResponseProcessor,
+               // which must run ahead of AsyncResponseProcessor). Inert on a 
bare juneau-rest-server classpath —
+               // when no module ships a 
META-INF/services/...ResponseProcessor provider file the list is empty and
+               // the chain is identical to the pre-feature default.
+               discoverServiceLoaderResponseProcessors().forEach(b::add);
                getRestAnnotationsForProperty(PROPERTY_responseProcessors)
                        .forEach(ai -> b.add(ai.inner().responseProcessors()));
                // @Bean method override REPLACES the entire annotation-derived 
list.
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index ca60da7b71..eb4523c9b4 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -42,6 +42,8 @@
                <module>juneau-rest-server-oidc-rp</module>
                <module>juneau-rest-server-micrometer</module>
                <module>juneau-rest-server-otel</module>
+               <module>juneau-rest-server-reactive</module>
+               <module>juneau-rest-server-reactor</module>
                <module>juneau-rest-server-view-jsp</module>
                <module>juneau-rest-server-view-thymeleaf</module>
                <module>juneau-rest-server-view-mustache</module>
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index 1a30065ec0..615921661f 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -541,6 +541,55 @@
                        <version>1.62.0</version>
                        <scope>test</scope>
                </dependency>
+               <!--
+                       Reactive-Streams return-type modules (both opt-in; the 
reactive feature is no longer in
+                       juneau-rest-server core / DefaultConfig — see 
FINISHED-119/120).
+
+                       - juneau-rest-server-reactive: dependency-free JDK 
Flow.Publisher spine + ReactiveStreamsAdapter
+                         SPI + the ServiceLoader auto-registration file for 
ReactiveResponseProcessor. Its mere presence
+                         on this test classpath is what activates the reactive 
processor for ReactiveResponseProcessor_Test
+                         (the JDK-native path needs no third-party library). 
Pulled transitively by reactor, but declared
+                         explicitly here for clarity and so the JDK-Flow tests 
don't silently depend on reactor.
+                       - juneau-rest-server-reactor: the Reactor / RxJava / 
Reactive-Streams adapter bridge. The module
+                         declares reactor-core / rxjava / reactive-streams in 
`provided` scope (so they never leak into
+                         consumers); juneau-utest exercises all three adapters 
end-to-end, so we add the runtime libs here
+                         in `test` scope explicitly.
+
+                       NOTE: because juneau-rest-server-reactive is on this 
classpath, EVERY resource in juneau-utest gets
+                       the ReactiveResponseProcessor auto-discovered. The 
"bare juneau-rest-server has zero reactive
+                       behavior" assertion therefore cannot live here — it is 
exercised in juneau-rest-mock (whose test
+                       classpath has no reactive module). See 
ReactiveOptIn_BareServer_Test.
+               -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-reactive</artifactId>
+                       <version>${project.version}</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-reactor</artifactId>
+                       <version>${project.version}</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>org.reactivestreams</groupId>
+                       <artifactId>reactive-streams</artifactId>
+                       <version>1.0.4</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.projectreactor</groupId>
+                       <artifactId>reactor-core</artifactId>
+                       <version>3.6.11</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.reactivex.rxjava3</groupId>
+                       <artifactId>rxjava</artifactId>
+                       <version>3.1.10</version>
+                       <scope>test</scope>
+               </dependency>
                <dependency>
                        <groupId>org.openjdk.jmh</groupId>
                        <artifactId>jmh-core</artifactId>
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/reactive/ReactiveResponseProcessor_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/reactive/ReactiveResponseProcessor_Test.java
new file mode 100644
index 0000000000..ba42ab1cd8
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/reactive/ReactiveResponseProcessor_Test.java
@@ -0,0 +1,251 @@
+/*
+ * 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.juneau.rest.reactive;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.sse.*;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link ReactiveResponseProcessor} using only the JDK-native
+ * {@link java.util.concurrent.Flow.Publisher Flow.Publisher} return type (no 
external reactive
+ * library). This is the MAYBE-120 spine: {@code Flow.Publisher<SseEvent>} SSE 
streaming plus the
+ * shared buffer / NDJSON shapes that all reactive types funnel through.
+ *
+ * <p>
+ * Under {@code MockRestClient} the underlying {@code MockServletRequest} 
reports
+ * {@code isAsyncSupported() == false}, so streaming exercises its 
synchronous-fallback path (subscribe
+ * and block until the publisher terminates, writing frames as they arrive) 
and buffering exercises the
+ * {@code AsyncResponseProcessor} synchronous fallback.
+ */
+class ReactiveResponseProcessor_Test extends TestBase {
+
+       public static final class Pojo {
+               public final String name;
+               public final int value;
+               public Pojo(String name, int value) { this.name = name; 
this.value = value; }
+       }
+
+       /**
+        * Trampolined synchronous {@link java.util.concurrent.Flow.Publisher 
Flow.Publisher} that replays a
+        * fixed list on subscribe, honoring {@code request(n)} without 
recursing on re-request (so the
+        * one-at-a-time streaming subscriber is exercised deterministically on 
a single thread).
+        */
+       static final class ListPublisher<T> implements Flow.Publisher<T> {
+               private final List<T> items;
+               ListPublisher(List<T> items) { this.items = items; }
+
+               @SafeVarargs
+               static <T> ListPublisher<T> of(T... items) { return new 
ListPublisher<>(List.of(items)); }
+
+               @Override public void subscribe(Flow.Subscriber<? super T> sub) 
{
+                       sub.onSubscribe(new Flow.Subscription() {
+                               private int idx;
+                               private final AtomicLong demand = new 
AtomicLong();
+                               private boolean draining;
+                               private volatile boolean cancelled;
+
+                               @Override public void request(long n) {
+                                       if (cancelled)
+                                               return;
+                                       demand.addAndGet(n);
+                                       if (draining)
+                                               return;
+                                       draining = true;
+                                       try {
+                                               while (! cancelled && 
demand.get() > 0 && idx < items.size()) {
+                                                       
demand.decrementAndGet();
+                                                       
sub.onNext(items.get(idx++));
+                                               }
+                                               if (! cancelled && idx >= 
items.size())
+                                                       sub.onComplete();
+                                       } finally {
+                                               draining = false;
+                                       }
+                               }
+
+                               @Override public void cancel() { cancelled = 
true; }
+                       });
+               }
+       }
+
+       /** Publisher that emits one element then errors. */
+       static final class ErrorPublisher<T> implements Flow.Publisher<T> {
+               private final T first;
+               private final boolean emitFirst;
+               ErrorPublisher(T first, boolean emitFirst) { this.first = 
first; this.emitFirst = emitFirst; }
+
+               @Override public void subscribe(Flow.Subscriber<? super T> sub) 
{
+                       sub.onSubscribe(new Flow.Subscription() {
+                               private boolean done;
+                               @Override public void request(long n) {
+                                       if (done)
+                                               return;
+                                       done = true;
+                                       if (emitFirst)
+                                               sub.onNext(first);
+                                       sub.onError(new 
RuntimeException("boom"));
+                               }
+                               @Override public void cancel() { done = true; }
+                       });
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: Buffer shape — default media type collects the stream into a JSON 
array.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(serializers = JsonSerializer.class)
+       public static class A {
+               @RestGet("/flux")
+               public Flow.Publisher<Pojo> flux() {
+                       return ListPublisher.of(new Pojo("foo", 1), new 
Pojo("bar", 2));
+               }
+
+               @RestGet("/empty")
+               public Flow.Publisher<Pojo> empty() {
+                       return ListPublisher.of();
+               }
+
+               @RestGet("/sse")
+               public Flow.Publisher<SseEvent> sse(RestResponse res) {
+                       res.setContentType("text/event-stream");
+                       return ListPublisher.of(new SseEvent("tick", "one"), 
new SseEvent("tick", "two"));
+               }
+
+               @RestGet("/sseNonEvent")
+               public Flow.Publisher<Pojo> sseNonEvent(RestResponse res) {
+                       res.setContentType("text/event-stream");
+                       return ListPublisher.of(new Pojo("foo", 1));
+               }
+
+               @RestGet("/ndjson")
+               public Flow.Publisher<Pojo> ndjson(RestResponse res) {
+                       res.setContentType("application/x-ndjson");
+                       return ListPublisher.of(new Pojo("foo", 1), new 
Pojo("bar", 2));
+               }
+
+               @RestGet("/errStream")
+               public Flow.Publisher<SseEvent> errStream(RestResponse res) {
+                       res.setContentType("text/event-stream");
+                       return new ErrorPublisher<>(new SseEvent("tick", 
"one"), true);
+               }
+       }
+
+       private static final MockRestClient CA = 
MockRestClient.buildLax(A.class);
+
+       @Test void a01_flux_buffersToJsonArray() throws Exception {
+               CA.get("/flux").accept("application/json").run()
+                       .assertStatus(200)
+                       .assertContent().isContains("\"name\":\"foo\"", 
"\"value\":1", "\"name\":\"bar\"", "\"value\":2");
+       }
+
+       @Test void a02_emptyFlux_buffersToEmptyArray() throws Exception {
+               
CA.get("/empty").accept("application/json").run().assertStatus(200).assertContent().is("[]");
+       }
+
+       @Test void a03_flowPublisherOfSseEvent_streamsSse() throws Exception {
+               var r = CA.get("/sse").run();
+               r.assertStatus(200);
+               r.assertHeader("Content-Type").isContains("text/event-stream");
+               var c = r.getContent().asString();
+               assertTrue(c.contains("event: tick"), c);
+               assertTrue(c.contains("data: one"), c);
+               assertTrue(c.contains("data: two"), c);
+       }
+
+       @Test void a04_sseWithNonEventElement_jsonEncodesData() throws 
Exception {
+               var c = 
CA.get("/sseNonEvent").run().assertStatus(200).getContent().asString();
+               assertTrue(c.contains("data:"), c);
+               assertTrue(c.contains("\"name\":\"foo\""), c);
+       }
+
+       @Test void a05_ndjson_writesOneJsonObjectPerLine() throws Exception {
+               var c = 
CA.get("/ndjson").run().assertStatus(200).getContent().asString();
+               var lines = c.strip().split("\n");
+               assertEquals(2, lines.length, c);
+               assertTrue(lines[0].contains("\"name\":\"foo\""), c);
+               assertTrue(lines[1].contains("\"name\":\"bar\""), c);
+       }
+
+       @Test void a06_streamErrorAfterFirstFrame_stillDeliversFirstFrame() 
throws Exception {
+               // First frame is committed before the error; SSE has no way to 
retract it, so the first
+               // event is delivered and the stream simply ends.
+               var c = CA.get("/errStream").run().getContent().asString();
+               assertTrue(c.contains("data: one"), c);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: Accept-header-driven SSE opt-in (serializer registered so 
negotiation passes).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(serializers = SseSerializer.class)
+       public static class B {
+               @RestGet("/events")
+               public Flow.Publisher<SseEvent> events() {
+                       return ListPublisher.of(new SseEvent("msg", "hello"));
+               }
+       }
+
+       private static final MockRestClient CB = 
MockRestClient.buildLax(B.class);
+
+       @Test void b01_acceptHeaderSelectsSse() throws Exception {
+               var r = CB.get("/events").header("Accept", 
"text/event-stream").run();
+               r.assertStatus(200);
+               r.assertHeader("Content-Type").isContains("text/event-stream");
+               assertTrue(r.getContent().asString().contains("data: hello"));
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: Non-reactive return values are untouched (processor is a 
transparent no-op).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(serializers = JsonSerializer.class)
+       public static class C {
+               @RestGet("/plain")
+               public Pojo plain() {
+                       return new Pojo("plain", 99);
+               }
+
+               @RestGet("/future")
+               public CompletableFuture<Pojo> future() {
+                       return CompletableFuture.completedFuture(new 
Pojo("fut", 7));
+               }
+       }
+
+       private static final MockRestClient CC = 
MockRestClient.buildLax(C.class);
+
+       @Test void c01_plainPojo_unaffected() throws Exception {
+               
CC.get("/plain").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"plain\"", 
"\"value\":99");
+       }
+
+       @Test void c02_completableFuture_stillHandledByAsyncProcessor() throws 
Exception {
+               
CC.get("/future").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"fut\"", 
"\"value\":7");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/reactive/bridge/ReactiveBridge_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/reactive/bridge/ReactiveBridge_Test.java
new file mode 100644
index 0000000000..704ffab9be
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/reactive/bridge/ReactiveBridge_Test.java
@@ -0,0 +1,191 @@
+/*
+ * 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.juneau.rest.reactive.bridge;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.List;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.sse.*;
+import org.junit.jupiter.api.Test;
+
+import io.reactivex.rxjava3.core.*;
+import reactor.core.publisher.*;
+
+/**
+ * End-to-end tests for the {@code juneau-rest-server-reactor} bridge adapters
+ * ({@link ReactorReactiveAdapter}, {@link RxJavaReactiveAdapter},
+ * {@link ReactiveStreamsPublisherAdapter}) wired through the core
+ * {@link org.apache.juneau.rest.reactive.ReactiveResponseProcessor} via 
ServiceLoader.
+ */
+class ReactiveBridge_Test extends TestBase {
+
+       public static final class Pojo {
+               public final String name;
+               public final int value;
+               public Pojo(String name, int value) { this.name = name; 
this.value = value; }
+       }
+
+       /** Minimal trampolined Reactive-Streams publisher, used to exercise 
the generic Publisher adapter. */
+       static final class RsListPublisher implements 
org.reactivestreams.Publisher<Pojo> {
+               private final List<Pojo> items;
+               RsListPublisher(List<Pojo> items) { this.items = items; }
+
+               @Override public void 
subscribe(org.reactivestreams.Subscriber<? super Pojo> sub) {
+                       sub.onSubscribe(new org.reactivestreams.Subscription() {
+                               private int idx;
+                               private final AtomicLong demand = new 
AtomicLong();
+                               private boolean draining;
+                               private volatile boolean cancelled;
+
+                               @Override public void request(long n) {
+                                       if (cancelled)
+                                               return;
+                                       demand.addAndGet(n);
+                                       if (draining)
+                                               return;
+                                       draining = true;
+                                       try {
+                                               while (! cancelled && 
demand.get() > 0 && idx < items.size()) {
+                                                       
demand.decrementAndGet();
+                                                       
sub.onNext(items.get(idx++));
+                                               }
+                                               if (! cancelled && idx >= 
items.size())
+                                                       sub.onComplete();
+                                       } finally {
+                                               draining = false;
+                                       }
+                               }
+
+                               @Override public void cancel() { cancelled = 
true; }
+                       });
+               }
+       }
+
+       @Rest(serializers = JsonSerializer.class)
+       public static class A {
+
+               // ---- Reactor ----
+               @RestGet("/mono")
+               public Mono<Pojo> mono() { return Mono.just(new Pojo("mono", 
1)); }
+
+               @RestGet("/monoEmpty")
+               public Mono<Pojo> monoEmpty() { return Mono.empty(); }
+
+               @RestGet("/flux")
+               public Flux<Pojo> flux() { return Flux.just(new Pojo("a", 1), 
new Pojo("b", 2)); }
+
+               @RestGet("/fluxSse")
+               public Flux<SseEvent> fluxSse(RestResponse res) {
+                       res.setContentType("text/event-stream");
+                       return Flux.just(new SseEvent("tick", "one"), new 
SseEvent("tick", "two"));
+               }
+
+               // ---- RxJava 3 ----
+               @RestGet("/single")
+               public Single<Pojo> single() { return Single.just(new 
Pojo("single", 3)); }
+
+               @RestGet("/maybe")
+               public Maybe<Pojo> maybe() { return Maybe.just(new 
Pojo("maybe", 4)); }
+
+               @RestGet("/maybeEmpty")
+               public Maybe<Pojo> maybeEmpty() { return Maybe.empty(); }
+
+               @RestGet("/completable")
+               public Completable completable() { return 
Completable.complete(); }
+
+               @RestGet("/flowable")
+               public Flowable<Pojo> flowable() { return Flowable.just(new 
Pojo("x", 5), new Pojo("y", 6)); }
+
+               @RestGet("/observable")
+               public Observable<Pojo> observable() { return 
Observable.just(new Pojo("o", 7), new Pojo("p", 8)); }
+
+               // ---- Generic Reactive-Streams Publisher ----
+               @RestGet("/publisher")
+               public org.reactivestreams.Publisher<Pojo> publisher() {
+                       return new RsListPublisher(List.of(new Pojo("pub", 9)));
+               }
+       }
+
+       private static final MockRestClient C = 
MockRestClient.buildLax(A.class);
+
+       // ---- Reactor ----
+
+       @Test void a01_mono_single() throws Exception {
+               
C.get("/mono").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"mono\"", 
"\"value\":1");
+       }
+
+       @Test void a02_monoEmpty_nullBody() throws Exception {
+               
C.get("/monoEmpty").accept("application/json").run().assertStatus(200);
+       }
+
+       @Test void a03_flux_buffersToArray() throws Exception {
+               
C.get("/flux").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"a\"", 
"\"name\":\"b\"");
+       }
+
+       @Test void a04_flux_streamsSse() throws Exception {
+               var c = 
C.get("/fluxSse").run().assertStatus(200).getContent().asString();
+               assertTrue(c.contains("event: tick"), c);
+               assertTrue(c.contains("data: one"), c);
+               assertTrue(c.contains("data: two"), c);
+       }
+
+       // ---- RxJava 3 ----
+
+       @Test void b01_single() throws Exception {
+               
C.get("/single").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"single\"", 
"\"value\":3");
+       }
+
+       @Test void b02_maybe() throws Exception {
+               
C.get("/maybe").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"maybe\"", 
"\"value\":4");
+       }
+
+       @Test void b03_maybeEmpty_nullBody() throws Exception {
+               
C.get("/maybeEmpty").accept("application/json").run().assertStatus(200);
+       }
+
+       @Test void b04_completable_nullBody() throws Exception {
+               
C.get("/completable").accept("application/json").run().assertStatus(200);
+       }
+
+       @Test void b05_flowable_buffersToArray() throws Exception {
+               
C.get("/flowable").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"x\"", 
"\"name\":\"y\"");
+       }
+
+       @Test void b06_observable_buffersToArray() throws Exception {
+               
C.get("/observable").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"o\"", 
"\"name\":\"p\"");
+       }
+
+       // ---- Generic Publisher ----
+
+       @Test void c01_reactiveStreamsPublisher_buffersToArray() throws 
Exception {
+               
C.get("/publisher").accept("application/json").run().assertStatus(200)
+                       .assertContent().isContains("\"name\":\"pub\"", 
"\"value\":9");
+       }
+}
diff --git a/juneau-utest/test-run-history.tsv 
b/juneau-utest/test-run-history.tsv
index e632c8c64f..7ce105bf82 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -54,3 +54,4 @@ timestamp     git_sha branch  tests_run       failures        
errors  skipped surefire_sec    wall_sec
 2026-05-29T10:45:54Z   3dc947b24d41    master  125897  0       0       21      
151
 2026-05-29T13:48:36Z   c59fc14fac5e    master  126037  0       0       21      
144
 2026-05-29T14:57:25Z   830e0ca051c6    master  126043  0       0       21      
149
+2026-05-29T16:30:02Z   29bd5a598e5a    master  126063  0       0       21      
148

Reply via email to