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 9d6f667d54 Complete TODO-62 SSE server helpers (broadcaster, per-event
flush, heartbeat)
9d6f667d54 is described below
commit 9d6f667d5468db7d118f68debf9c0b3477a27cc2
Author: James Bognar <[email protected]>
AuthorDate: Fri May 22 18:42:47 2026 -0400
Complete TODO-62 SSE server helpers (broadcaster, per-event flush,
heartbeat)
---
.../juneau/examples/rest/SseDemoResource.java | 38 +++++
.../java/org/apache/juneau/rest/RestResponse.java | 11 ++
.../apache/juneau/rest/arg/SseBroadcasterArg.java | 48 ++++++
.../apache/juneau/rest/arg/SseSubscriptionArg.java | 54 +++++++
.../apache/juneau/rest/config/DefaultConfig.java | 2 +
.../org/apache/juneau/rest/sse/SseBroadcaster.java | 96 ++++++++++++
.../org/apache/juneau/rest/sse/SseHeartbeat.java | 85 +++++++++++
.../apache/juneau/rest/sse/SseResponseSupport.java | 161 +++++++++++++++++++++
.../apache/juneau/rest/sse/SseSubscription.java | 114 +++++++++++++++
.../juneau/rest/Rest_SseBroadcaster_IT_Test.java | 53 +++++++
.../juneau/rest/sse/SseBroadcaster_Test.java | 59 ++++++++
.../apache/juneau/rest/sse/SseHeartbeat_Test.java | 41 ++++++
.../juneau/rest/sse/SseResponseSupport_Test.java | 53 +++++++
todo/FINISHED-62-sse-server-helpers.md | 17 +++
todo/TODO-62-sse-server-helpers.md | 113 ---------------
todo/TODO.md | 2 -
16 files changed, 832 insertions(+), 115 deletions(-)
diff --git
a/juneau-examples/juneau-examples-rest/src/main/java/org/apache/juneau/examples/rest/SseDemoResource.java
b/juneau-examples/juneau-examples-rest/src/main/java/org/apache/juneau/examples/rest/SseDemoResource.java
index abca46bada..093211dfe7 100644
---
a/juneau-examples/juneau-examples-rest/src/main/java/org/apache/juneau/examples/rest/SseDemoResource.java
+++
b/juneau-examples/juneau-examples-rest/src/main/java/org/apache/juneau/examples/rest/SseDemoResource.java
@@ -16,11 +16,16 @@
*/
package org.apache.juneau.examples.rest;
+import java.io.*;
import java.time.*;
import java.time.format.*;
import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+import org.apache.juneau.rest.*;
import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.sse.*;
import org.apache.juneau.rest.servlet.*;
import org.apache.juneau.sse.*;
@@ -89,4 +94,37 @@ public class SseDemoResource extends BasicRestObject {
}
};
}
+
+ /**
+ * Demonstrates server-side broadcaster fan-out with heartbeat comments.
+ *
+ * @param req The request.
+ * @param res The response.
+ * @param broadcaster The broadcaster bean.
+ * @throws IOException If the response write fails.
+ */
+ @RestGet(path = "/broadcast", serializers = SseSerializer.class)
+ @SuppressWarnings({
+ "resource" // Scheduler is explicitly shutdown in finally;
subscription/SSE support are closed via try-with-resources.
+ })
+ public void broadcast(RestRequest req, RestResponse res, SseBroadcaster
broadcaster) throws IOException {
+ var id =
Optional.ofNullable(req.getHttpServletRequest().getRequestId()).orElse(UUID.randomUUID().toString());
+ var counter = new AtomicInteger();
+ var scheduler = Executors.newSingleThreadScheduledExecutor();
+ ScheduledFuture<?> task = null;
+ try (var subscription = broadcaster.subscribe(id); var sse =
res.sse().heartbeat(Duration.ofSeconds(15))) {
+ task = scheduler.scheduleAtFixedRate(() -> {
+ var i = counter.incrementAndGet();
+ var ts =
DateTimeFormatter.ISO_INSTANT.format(Instant.now());
+ broadcaster.publish(new SseEvent("tick-" + i,
ts).setId(String.valueOf(i)));
+ if (i >= EVENT_COUNT)
+ subscription.close();
+ }, 0, SLEEP_MILLIS, TimeUnit.MILLISECONDS);
+ sse.sendFrom(subscription);
+ } finally {
+ if (task != null)
+ task.cancel(true);
+ scheduler.shutdownNow();
+ }
+ }
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
index 504496ea46..b966c94b77 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
@@ -39,6 +39,7 @@ import org.apache.juneau.marshaller.*;
import org.apache.juneau.oapi.*;
import org.apache.juneau.rest.httppart.*;
import org.apache.juneau.rest.logger.*;
+import org.apache.juneau.rest.sse.*;
import org.apache.juneau.rest.util.*;
import org.apache.juneau.serializer.*;
@@ -535,6 +536,16 @@ public class RestResponse extends
HttpServletResponseWrapper {
return this;
}
+ /**
+ * Creates a fluent Server-Sent Events helper for this response.
+ *
+ * @return The SSE response helper.
+ * @throws IOException If the writer could not be created.
+ */
+ public SseResponseSupport sse() throws IOException {
+ return new SseResponseSupport(this);
+ }
+
/**
* Redirects to the specified URI.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/SseBroadcasterArg.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/SseBroadcasterArg.java
new file mode 100644
index 0000000000..34fff94dff
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/SseBroadcasterArg.java
@@ -0,0 +1,48 @@
+/*
+ * 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.arg;
+
+import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.rest.sse.*;
+
+/**
+ * Resolves {@link SseBroadcaster} method parameters.
+ */
+public class SseBroadcasterArg extends SimpleRestOperationArg {
+
+ /**
+ * Static creator.
+ *
+ * @param paramInfo The Java method parameter being resolved.
+ * @return A new arg, or {@code null} if not applicable.
+ */
+ public static SseBroadcasterArg create(ParameterInfo paramInfo) {
+ if (paramInfo.isType(SseBroadcaster.class))
+ return new SseBroadcasterArg();
+ return null;
+ }
+
+ /**
+ * Constructor.
+ */
+ @SuppressWarnings({
+ "resource" // Broadcaster bean is container-managed in
BeanStore and intentionally not closed by arg resolver.
+ })
+ protected SseBroadcasterArg() {
+ super(opSession ->
opSession.getBeanStore().getBean(SseBroadcaster.class).orElseGet(() ->
opSession.getBeanStore().add(SseBroadcaster.class, SseBroadcaster.create())));
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/SseSubscriptionArg.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/SseSubscriptionArg.java
new file mode 100644
index 0000000000..d18625acb8
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/SseSubscriptionArg.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.arg;
+
+import java.util.*;
+
+import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.rest.sse.*;
+
+/**
+ * Resolves {@link SseSubscription} method parameters.
+ */
+public class SseSubscriptionArg extends SimpleRestOperationArg {
+
+ /**
+ * Static creator.
+ *
+ * @param paramInfo The Java method parameter being resolved.
+ * @return A new arg, or {@code null} if not applicable.
+ */
+ public static SseSubscriptionArg create(ParameterInfo paramInfo) {
+ if (paramInfo.isType(SseSubscription.class))
+ return new SseSubscriptionArg();
+ return null;
+ }
+
+ /**
+ * Constructor.
+ */
+ @SuppressWarnings({
+ "resource" // Subscription is caller-owned and closed by SSE
response handling.
+ })
+ protected SseSubscriptionArg() {
+ super(opSession -> {
+ var broadcaster =
opSession.getBeanStore().getBean(SseBroadcaster.class).orElseGet(() ->
opSession.getBeanStore().add(SseBroadcaster.class, SseBroadcaster.create()));
+ var id =
Optional.ofNullable(opSession.getRequest().getHttpServletRequest().getRequestId()).orElse(UUID.randomUUID().toString());
+ return broadcaster.subscribe(id);
+ });
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
index 180cde8f32..22b3401eb1 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
@@ -87,6 +87,8 @@ import org.apache.juneau.serializer.annotation.*;
RestOpSessionArgs.class,
RestRequestArgs.class,
RestResponseArgs.class,
+ SseBroadcasterArg.class,
+ SseSubscriptionArg.class,
DefaultArg.class
},
serializers={},
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseBroadcaster.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseBroadcaster.java
new file mode 100644
index 0000000000..e4a6b50fc7
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseBroadcaster.java
@@ -0,0 +1,96 @@
+/*
+ * 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.sse;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.util.concurrent.*;
+import java.util.logging.*;
+
+import org.apache.juneau.sse.*;
+
+/**
+ * In-memory server-side SSE broadcaster.
+ */
+public class SseBroadcaster {
+
+ private static final Logger LOGGER =
Logger.getLogger(SseBroadcaster.class.getName());
+ private static final int DEFAULT_QUEUE_SIZE = 1024;
+
+ /**
+ * Creates a broadcaster with the default queue size.
+ *
+ * @return A new broadcaster.
+ */
+ public static SseBroadcaster create() {
+ return new SseBroadcaster(DEFAULT_QUEUE_SIZE);
+ }
+
+ private final int queueSize;
+ private final ConcurrentMap<String,SseSubscription> subscriptions;
+
+ /**
+ * Constructor.
+ *
+ * @param queueSize The per-subscriber queue size.
+ */
+ public SseBroadcaster(int queueSize) {
+ if (queueSize <= 0)
+ throw illegalArg("queueSize must be greater than 0.");
+ this.queueSize = queueSize;
+ subscriptions = new ConcurrentHashMap<>();
+ }
+
+ /**
+ * Creates or replaces a subscriber.
+ *
+ * @param id The subscriber identifier.
+ * @return A new subscription.
+ */
+ @SuppressWarnings({
+ "resource" // Returned subscription is caller-owned and closed
by the caller/framework.
+ })
+ public SseSubscription subscribe(String id) {
+ if (id == null || id.isEmpty())
+ throw illegalArg("id cannot be null or empty.");
+ var subscription = new SseSubscription(id, queueSize,
this::removeSubscriber);
+ var previous = subscriptions.put(id, subscription);
+ if (previous != null)
+ previous.close();
+ return subscription;
+ }
+
+ /**
+ * Publishes an event to all active subscribers.
+ *
+ * @param event The event to publish.
+ */
+ public void publish(SseEvent event) {
+ if (event == null)
+ return;
+ subscriptions.values().forEach(x -> {
+ if (x.offer(event))
+ LOGGER.fine(() -> "SSE queue overflow for
subscriber " + x.getId() + ", dropped oldest event.");
+ });
+ }
+
+ void removeSubscriber(String id) {
+ var removed = subscriptions.remove(id);
+ if (removed != null && ! removed.isClosed())
+ removed.close();
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseHeartbeat.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseHeartbeat.java
new file mode 100644
index 0000000000..58ae03bd7f
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseHeartbeat.java
@@ -0,0 +1,85 @@
+/*
+ * 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.sse;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.io.*;
+import java.time.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.sse.*;
+
+/**
+ * Scheduled SSE heartbeat.
+ */
+public class SseHeartbeat implements Runnable, AutoCloseable {
+
+ /**
+ * Starts a heartbeat.
+ *
+ * @param scheduler The scheduler.
+ * @param writer The writer.
+ * @param interval The heartbeat interval.
+ * @return The heartbeat handle.
+ */
+ @SuppressWarnings({
+ "resource" // Returned heartbeat handle is caller-owned and
must be closed by the caller.
+ })
+ public static SseHeartbeat start(ScheduledExecutorService scheduler,
Writer writer, Duration interval) {
+ var heartbeat = new SseHeartbeat(writer);
+ var i = assertArgNotNull("interval", interval).toMillis();
+ assertArg(i > 0, "interval must be > 0.");
+ heartbeat.future = scheduler.scheduleAtFixedRate(heartbeat, i,
i, TimeUnit.MILLISECONDS);
+ return heartbeat;
+ }
+
+ @SuppressWarnings({
+ "resource" // Writer is borrowed from response lifecycle and
not owned by heartbeat.
+ })
+ private final Writer writer;
+ private final AtomicBoolean closed;
+ private ScheduledFuture<?> future;
+
+ /**
+ * Constructor.
+ *
+ * @param writer The writer.
+ */
+ public SseHeartbeat(Writer writer) {
+ this.writer = assertArgNotNull("writer", writer);
+ closed = new AtomicBoolean();
+ }
+
+ @Override /* Runnable */
+ public void run() {
+ if (closed.get())
+ return;
+ try {
+ SseSerializer.writeComment(writer, "ping");
+ } catch (IOException e) {
+ close();
+ }
+ }
+
+ @Override /* AutoCloseable */
+ public void close() {
+ if (closed.compareAndSet(false, true) && future != null)
+ future.cancel(false);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseResponseSupport.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseResponseSupport.java
new file mode 100644
index 0000000000..009f4451f0
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseResponseSupport.java
@@ -0,0 +1,161 @@
+/*
+ * 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.sse;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.io.*;
+import java.time.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.util.*;
+import org.apache.juneau.sse.*;
+
+/**
+ * Fluent SSE response helper.
+ */
+public class SseResponseSupport implements AutoCloseable {
+
+ private final RestResponse response;
+ @SuppressWarnings({
+ "resource" // Writer is response-owned and intentionally not
closed by this wrapper.
+ })
+ private final FinishablePrintWriter writer;
+ @SuppressWarnings({
+ "resource" // Scheduler is BeanStore-managed and shared; this
wrapper must not close it.
+ })
+ private final ScheduledExecutorService scheduler;
+ @SuppressWarnings({
+ "resource" // Heartbeat lifecycle is controlled by this wrapper
and closed in close()/heartbeat().
+ })
+ private SseHeartbeat heartbeat;
+
+ /**
+ * Constructor.
+ *
+ * @param response The REST response.
+ * @throws IOException If the writer could not be created.
+ */
+ @SuppressWarnings({
+ "resource" // BeanStore returns container-managed scheduler
reference; this wrapper borrows it.
+ })
+ public SseResponseSupport(RestResponse response) throws IOException {
+ this.response = assertArgNotNull("response", response);
+ this.scheduler =
response.getContext().getBeanStore().getBean(ScheduledExecutorService.class).orElse(null);
+ response.setContentType("text/event-stream");
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.setHeader("Content-Encoding", "identity");
+ writer = response.getNegotiatedWriter();
+ }
+
+ /**
+ * Starts periodic heartbeat comments.
+ *
+ * @param interval The heartbeat interval.
+ * @return This object.
+ */
+ public SseResponseSupport heartbeat(Duration interval) {
+ if (scheduler != null) {
+ if (heartbeat != null)
+ heartbeat.close();
+ heartbeat = SseHeartbeat.start(scheduler, writer,
interval);
+ }
+ return this;
+ }
+
+ /**
+ * Sends an SSE event.
+ *
+ * @param event The event.
+ * @return This object.
+ * @throws IOException If an I/O error occurred.
+ */
+ public SseResponseSupport sendEvent(SseEvent event) throws IOException {
+ SseSerializer.DEFAULT.serialize(event, writer);
+ return this;
+ }
+
+ /**
+ * Sends an SSE event from name+data values.
+ *
+ * @param name The event name.
+ * @param data The event data.
+ * @return This object.
+ * @throws IOException If an I/O error occurred.
+ */
+ @SuppressWarnings("resource")
+ public SseResponseSupport sendEvent(String name, Object data) throws
IOException {
+ return sendEvent(new SseEvent(name, data == null ? null :
data.toString()));
+ }
+
+ /**
+ * Sends a heartbeat/comment line.
+ *
+ * @param value The comment value.
+ * @return This object.
+ * @throws IOException If an I/O error occurred.
+ */
+ public SseResponseSupport comment(String value) throws IOException {
+ SseSerializer.writeComment(writer, value);
+ return this;
+ }
+
+ /**
+ * Flushes pending output.
+ *
+ * @return This object.
+ * @throws IOException If an I/O error occurred.
+ */
+ public SseResponseSupport flush() throws IOException {
+ writer.flush();
+ response.flushBuffer();
+ return this;
+ }
+
+ /**
+ * Drains a subscription until disconnect or interruption.
+ *
+ * @param subscription The subscription.
+ * @return This object.
+ * @throws IOException If an I/O error occurred.
+ */
+ @SuppressWarnings({
+ "resource" // Subscription lifecycle is handled in finally and
by caller contract.
+ })
+ public SseResponseSupport sendFrom(SseSubscription subscription) throws
IOException {
+ assertArgNotNull("subscription", subscription);
+ try {
+ while (! subscription.isClosed()) {
+ sendEvent(subscription.take());
+ flush();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ subscription.close();
+ }
+ return this;
+ }
+
+ @Override /* AutoCloseable */
+ public void close() {
+ if (heartbeat != null)
+ heartbeat.close();
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseSubscription.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseSubscription.java
new file mode 100644
index 0000000000..5e6c28c65c
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/sse/SseSubscription.java
@@ -0,0 +1,114 @@
+/*
+ * 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.sse;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+import java.util.function.*;
+
+import org.apache.juneau.sse.*;
+
+/**
+ * Subscriber queue for an {@link SseBroadcaster}.
+ */
+public class SseSubscription implements AutoCloseable, Iterable<SseEvent> {
+
+ private final String id;
+ private final LinkedBlockingDeque<SseEvent> queue;
+ private final AtomicBoolean closed;
+ private final Consumer<String> closeCallback;
+
+ SseSubscription(String id, int queueSize, Consumer<String>
closeCallback) {
+ if (id == null || id.isEmpty())
+ throw illegalArg("id cannot be null or empty.");
+ this.id = id;
+ this.queue = new LinkedBlockingDeque<>(queueSize);
+ this.closeCallback = assertArgNotNull("closeCallback",
closeCallback);
+ closed = new AtomicBoolean(false);
+ }
+
+ /**
+ * The subscriber identifier.
+ *
+ * @return The subscriber identifier.
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Returns whether this subscription has been closed.
+ *
+ * @return {@code true} if this subscription has been closed.
+ */
+ public boolean isClosed() {
+ return closed.get();
+ }
+
+ boolean offer(SseEvent event) {
+ if (isClosed())
+ return false;
+ var dropped = false;
+ while (! queue.offerLast(event)) {
+ queue.pollFirst();
+ dropped = true;
+ }
+ return dropped;
+ }
+
+ /**
+ * Blocks until the next event is available.
+ *
+ * @return The next event.
+ * @throws InterruptedException If the wait was interrupted.
+ */
+ public SseEvent take() throws InterruptedException {
+ return queue.takeFirst();
+ }
+
+ @Override /* Iterable */
+ public Iterator<SseEvent> iterator() {
+ return new Iterator<>() {
+ @Override
+ public boolean hasNext() {
+ return ! isClosed();
+ }
+
+ @Override
+ public SseEvent next() {
+ try {
+ return take();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new
NoSuchElementException("Interrupted while waiting for SSE event.");
+ }
+ }
+ };
+ }
+
+ @Override /* AutoCloseable */
+ public void close() {
+ if (closed.compareAndSet(false, true)) {
+ queue.clear();
+ closeCallback.accept(id);
+ }
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_SseBroadcaster_IT_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_SseBroadcaster_IT_Test.java
new file mode 100644
index 0000000000..5cf562ee00
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_SseBroadcaster_IT_Test.java
@@ -0,0 +1,53 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.sse.*;
+import org.apache.juneau.sse.*;
+import org.junit.jupiter.api.*;
+
+class Rest_SseBroadcaster_IT_Test {
+
+ @Rest(serializers = SseSerializer.class)
+ public static class A {
+ @RestGet("/a")
+ public void a(RestResponse res, SseBroadcaster broadcaster)
throws Exception {
+ var subscription = broadcaster.subscribe("a");
+ broadcaster.publish(new SseEvent("tick", "one"));
+ broadcaster.publish(new SseEvent("tick", "two"));
+ try (var sse = res.sse()) {
+ sse.sendEvent(subscription.take());
+ sse.sendEvent(subscription.take());
+ sse.flush();
+ } finally {
+ subscription.close();
+ }
+ }
+ }
+
+ @Test
+ void a01_broadcasterArgCanBeResolved() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ var b = a.get("/a").header("Accept",
"text/event-stream").run().getContent().asString();
+ assertTrue(b.contains("data: one"));
+ assertTrue(b.contains("data: two"));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseBroadcaster_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseBroadcaster_Test.java
new file mode 100644
index 0000000000..455f93c5c9
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseBroadcaster_Test.java
@@ -0,0 +1,59 @@
+/*
+ * 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.sse;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.sse.*;
+import org.junit.jupiter.api.*;
+
+class SseBroadcaster_Test {
+
+ @Test
+ void a01_publishFanoutToMultipleSubscribers() throws Exception {
+ var a = SseBroadcaster.create();
+ try (var b = a.subscribe("b"); var c = a.subscribe("c")) {
+ a.publish(new SseEvent("e", "d1"));
+ a.publish(new SseEvent("e", "d2"));
+
+ assertEquals("d1", b.take().getData());
+ assertEquals("d2", b.take().getData());
+ assertEquals("d1", c.take().getData());
+ assertEquals("d2", c.take().getData());
+ }
+ }
+
+ @Test
+ void a02_slowSubscriberDropsOldest() throws Exception {
+ var a = new SseBroadcaster(1);
+ try (var b = a.subscribe("b")) {
+ a.publish(new SseEvent("e", "d1"));
+ a.publish(new SseEvent("e", "d2"));
+
+ assertEquals("d2", b.take().getData());
+ }
+ }
+
+ @Test
+ void a03_closeSubscriptionStopsDelivery() throws Exception {
+ var a = SseBroadcaster.create();
+ try (var b = a.subscribe("b")) {
+ b.close();
+ assertTrue(b.isClosed());
+ }
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseHeartbeat_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseHeartbeat_Test.java
new file mode 100644
index 0000000000..b582cd0c71
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseHeartbeat_Test.java
@@ -0,0 +1,41 @@
+/*
+ * 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.sse;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.time.*;
+import java.util.concurrent.*;
+
+import org.junit.jupiter.api.*;
+
+class SseHeartbeat_Test {
+
+ @Test
+ void a01_heartbeatWritesPingAndCancels() throws Exception {
+ var a = Executors.newSingleThreadScheduledExecutor();
+ var b = new StringWriter();
+ var c = SseHeartbeat.start(a, b, Duration.ofMillis(10));
+ for (var i = 0; i < 50 && ! b.toString().contains(": ping");
i++)
+ Thread.sleep(10);
+ c.close();
+ var d = b.toString();
+ assertTrue(d.contains(": ping"));
+ a.shutdownNow();
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseResponseSupport_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseResponseSupport_Test.java
new file mode 100644
index 0000000000..0a0136cab4
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/sse/SseResponseSupport_Test.java
@@ -0,0 +1,53 @@
+/*
+ * 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.sse;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+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.*;
+
+class SseResponseSupport_Test {
+
+ @Rest(serializers = SseSerializer.class)
+ public static class A {
+ @RestGet("/stream")
+ public void stream(RestResponse res) throws Exception {
+ try (var sse = res.sse()) {
+ sse.sendEvent("tick", "one");
+ sse.comment("ping");
+ sse.sendEvent(new SseEvent("tick", "two"));
+ sse.flush();
+ }
+ }
+ }
+
+ @Test
+ void a01_emitsEventsAndComments() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ var b = a.get("/stream").header("Accept",
"text/event-stream").run();
+ b.assertHeader("Content-Type").isContains("text/event-stream");
+ var c = b.getContent().asString();
+ assertTrue(c.contains("event: tick"));
+ assertTrue(c.contains("data: one"));
+ assertTrue(c.contains(": ping"));
+ assertTrue(c.contains("data: two"));
+ }
+}
diff --git a/todo/FINISHED-62-sse-server-helpers.md
b/todo/FINISHED-62-sse-server-helpers.md
new file mode 100644
index 0000000000..5dbec20752
--- /dev/null
+++ b/todo/FINISHED-62-sse-server-helpers.md
@@ -0,0 +1,17 @@
+# FINISHED-62: Server-side SSE helpers (broadcaster, per-event flush,
heartbeat)
+
+Completed on 2026-05-22.
+
+## Delivered
+
+- Added `RestResponse.sse()` and
`org.apache.juneau.rest.sse.SseResponseSupport` for fluent SSE response writes.
+- Added `SseBroadcaster`, `SseSubscription`, and `SseHeartbeat` in
`juneau-rest-server`.
+- Added `SseBroadcasterArg` and `SseSubscriptionArg` and wired them into
default `restOpArgs`.
+- Added SSE helper/broadcaster tests in `juneau-utest`.
+- Updated `SseDemoResource` with a broadcaster endpoint example.
+- Added docs page `pages/topics/10.08.RestServerSse.md`, sidebar entry, and a
`9.5.0` release note entry.
+
+## TODO updates
+
+- Archived this plan as finished.
+- Removed `[TODO-62]` from `todo/TODO.md`.
diff --git a/todo/TODO-62-sse-server-helpers.md
b/todo/TODO-62-sse-server-helpers.md
deleted file mode 100644
index 3f3a0eec49..0000000000
--- a/todo/TODO-62-sse-server-helpers.md
+++ /dev/null
@@ -1,113 +0,0 @@
-# TODO-62: Server-side SSE helpers (broadcaster, per-event flush, heartbeat)
-
-Source: split out of TODO-18 brainstorm on 2026-05-22 (the #2 pick).
-
-## Goal
-
-Build the server-side ergonomic layer on top of the SSE marshaller landed in
9.5.0 (`FINISHED-46-juneau-marshall-sse.md`). Today `@RestGet Stream<SseEvent>`
works end-to-end (per-event flush is wired in `SseSerializerSession`), but
writing a real SSE endpoint still requires hand-rolled glue: per-connection
broadcaster fan-out, named heartbeat / keepalive scheduling, and a clean
`res.sendEvent(name, data).flush()` idiom. Add:
-
-- A `SseResponseSupport` mix-in (or convenience methods on `RestResponse`)
that lets a `@RestGet` handler emit individual events without juggling `Writer`
state.
-- An `SseBroadcaster` bean that fan-outs to N subscribers from a single
producer (server-side event bus).
-- A `SseHeartbeat` scheduler (`@Bean ScheduledExecutorService`-driven) that
emits `: ping` comments at a configurable cadence so corporate proxies don't
kill idle SSE streams after 30s.
-
-End-state developer experience:
-
-```java
-@RestGet("/stream")
-public void stream(RestRequest req, RestResponse res, SseBroadcaster bus) {
- var sub = bus.subscribe(req.getRequestId());
- res.sse() // sets Content-Type, disables
buffering, starts heartbeat
- .heartbeat(Duration.ofSeconds(15))
- .sendFrom(sub); // drains events from this
subscriber until disconnect
-}
-```
-
-## Why now
-
-- The marshaller-side primitives shipped in 9.5 (`SseSerializer`, `SseParser`,
`SseEvent`, `SseEventReader`, `SseSerializerSession` with `Writer.flush()` per
event). See `FINISHED-46-juneau-marshall-sse.md`.
-- The archive plan explicitly parked the server-side ergonomic layer:
*"Returning a reactive-streams `Publisher<SseEvent>` is out of scope
(Juneau-rest has no reactive-streams plumbing in the response pipeline today)"*
— but the simpler push-from-server case is a clean follow-on.
-- `juneau-microservice` now exposes a `WritableBeanStore` (TODO-31) so a
`SseBroadcaster` registered as `@Bean` is auto-wired into resources.
-- `BasicRestServletGroup.addChild(...)` (TODO-33) makes it easy to mount an
SSE demo / health-stream child resource dynamically.
-
-## Scope
-
-**In scope (v1):**
-
-- `org.apache.juneau.rest.sse.SseResponseSupport` (or `RestResponse.sse()`
accessor) — fluent surface for `setContent-Type` to `text/event-stream`,
disable response buffering, expose `sendEvent(SseEvent)` / `sendEvent(String
name, Object data)` / `comment(String)` / `flush()` / `close()`.
-- `org.apache.juneau.rest.sse.SseBroadcaster` — pub/sub fan-out bean. Methods:
`subscribe(String id)` returns a `SseSubscription` (a `BlockingQueue<SseEvent>`
wrapper with `Iterator<SseEvent>` and `close()`); `publish(SseEvent)` enqueues
to every active subscriber; per-subscriber bounded queue with a configurable
overflow policy (default: drop-oldest with a debug log).
-- `org.apache.juneau.rest.sse.SseHeartbeat` — `ScheduledFuture`-driven `:
ping\n\n` emitter; defaults to 15s cadence; cancellable via the returned handle.
-- New `@RestGet`-friendly parameter `SseBroadcaster` / `SseSubscription`
injection through the existing `RestOpArg` SPI (sibling of the existing
`HttpServletRequestArgs`, `RestRequestArgs`).
-- Demo endpoint added under `juneau-examples/juneau-examples-rest` exercising
a broadcaster + heartbeat (the SSE-marshalling demo `SseDemoResource` is the
obvious place to grow into a broadcaster example).
-- Tests in `juneau-utest` covering: single-subscriber drain, multi-subscriber
fan-out, slow-subscriber overflow, heartbeat insertion, client-disconnect
cleanup (the writer throws — broadcaster must release the subscription).
-- Release-notes entry under `### juneau-rest-server` in the active
release-notes file; new topic page (`pages/topics/10.08.RestServerSse.md` or
similar).
-
-**Explicitly out of scope (v1):**
-
-- Reactive-Streams `Publisher<SseEvent>` return types from `@RestOp` —
orthogonal to TODO-70 (`CompletableFuture` + virtual-threads); the brainstorm
marked it as "transport-layer change, not marshalling change."
-- `Last-Event-ID` resume support (client-side concern; the bean already
carries `id`; server-side resume would need a per-resource event journal —
defer).
-- Cross-JVM broadcasting (Redis / Kafka backplane). The `SseBroadcaster` SPI
should be split into interface + in-memory impl so an external-backplane impl
can be a sibling sub-module later, but no external impl in v1.
-- Client-side SSE consumer ergonomics — `juneau-rest-client` already gets
`SseEventReader` from the marshall module; if more is wanted, file a separate
TODO.
-
-## Phased steps
-
-### Phase 0 — confirm seams (read-only)
-
-1. Re-read
`juneau-core/juneau-marshall/src/main/java/org/apache/juneau/sse/SseSerializerSession.java`
to confirm the per-event flush contract — it already calls `Writer.flush()`
per event, which is what makes the broadcaster path safe.
-2. Inspect
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java`
to confirm the response is **not** drained / closed by the framework when a
method calls `res.flushBuffer()` and writes directly — this is the seam the
`SseResponseSupport` rides on. (Today's `SseDemoResource` proves the pattern
works.)
-3. Confirm `RestResponse.getNegotiatedWriter()` returns the same `Writer`
`SseSerializerSession` operates on — yes, via `FinishablePrintWriter`.
-
-### Phase 1 — `SseResponseSupport` (no broadcaster)
-
-1. Add the new package `org.apache.juneau.rest.sse` in `juneau-rest-server`.
Add `SseResponseSupport` with the fluent surface, plus the `RestResponse.sse()`
accessor.
-2. Add `SseHeartbeat` (a small `Runnable` that writes a comment + flushes) and
wire the optional scheduler bean — when absent, `heartbeat(Duration)` is a
no-op (no scheduler ⇒ no heartbeat).
-3. Tests:
- - `SseResponseSupport_Test` — single-event emit, multi-event emit, comment
write, charset is UTF-8, content-type is `text/event-stream` exactly.
- - `SseHeartbeat_Test` — heartbeat fires at the configured cadence, cancels
on `close()`.
-
-### Phase 2 — `SseBroadcaster` + arg injection
-
-1. Add `SseBroadcaster` + `SseSubscription`. Default impl is in-memory with
per-subscriber `LinkedBlockingQueue<SseEvent>` and a configurable bound
(default: 1024 events).
-2. Add `SseBroadcasterArg` / `SseSubscriptionArg` `RestOpArg` implementations
so handlers can take them as parameters.
-3. Tests:
- - `SseBroadcaster_Test` — pub/sub fan-out, slow-subscriber overflow policy,
subscriber-disconnect cleanup, concurrent publisher / subscriber smoke.
- - `Rest_SseBroadcaster_IT_Test` (in `juneau-utest`) — `MockRestClient`
against a `@RestGet` using the broadcaster; assert both subscribers receive
every published event in order.
-
-### Phase 3 — demo + docs
-
-1. Update `juneau-examples/juneau-examples-rest/.../SseDemoResource.java` to
demonstrate the broadcaster pattern (keep the existing `Stream<SseEvent>`
example; add a new endpoint that uses `SseBroadcaster`).
-2. New doc page `juneau-docs/pages/topics/10.08.RestServerSse.md` (slug
`RestServerSse`) covering both the simple `Stream<SseEvent>` form and the
broadcaster form. Sidebar entry.
-3. Release-notes entry under `### juneau-rest-server`.
-
-## Acceptance criteria
-
-- [ ] `RestResponse.sse()` returns an `SseResponseSupport` that sets
`Content-Type: text/event-stream`, disables response buffering, and exposes
`sendEvent(...)` / `comment(...)` / `flush()` / `close()`.
-- [ ] `SseBroadcaster.publish(event)` reaches every active subscriber, in
order, with no drops below the per-subscriber bound. Slow-subscriber overflow
drops the oldest event and logs at `DEBUG`.
-- [ ] `SseHeartbeat` at a 15s cadence inserts `: ping\n\n` between events
without corrupting the SSE stream (verified by `SseEventReader` parsing the
captured output).
-- [ ] Client disconnect → broadcaster releases the subscription within ≤ 1
heartbeat interval (no leak in long-soak test).
-- [ ] Demo endpoint in `juneau-examples-rest` is observable via `curl -N` and
shows live event delivery.
-- [ ] Coverage ≥ 90% on the new package. Full `./scripts/test.py` green.
-- [ ] Release-notes + topic page + sidebar entry shipped.
-
-## Open questions
-
-1. **Mix-in vs accessor.** `RestResponse.sse()` accessor (recommended) vs a
separate `SseRestResponse extends RestResponse` mix-in. Accessor keeps the API
surface small and avoids subclass churn.
-2. **Overflow policy default.** Drop-oldest (recommended) vs drop-newest vs
block-publisher. Drop-oldest matches what most SSE consumers expect.
-3. **Per-subscriber queue bound default.** 1024 events / ~1MB worst case.
Configurable per subscriber; configurable per broadcaster via `@Bean
SseBroadcasterConfig`.
-4. **Heartbeat cadence default.** 15s — under Nginx's default 30s idle timeout
and AWS ALB's 60s default. Configurable.
-5. **External-backplane SPI surface.** Should v1 ship `SseBroadcaster` as an
interface (recommended) or a concrete class with hooks? Interface keeps the
door open for Redis / Kafka backplanes as separate sub-modules without breaking
changes.
-6. **Naming.** `SseBroadcaster` or `SseEventBus`? Recommend `SseBroadcaster` —
closer to the spec's "broadcasting" language.
-
-## Risks
-
-- **Servlet container buffering.** Some containers buffer the response despite
`flushBuffer()`. Mitigation: the existing SSE demo proves it works in Jetty
(per `FINISHED-46-*` verification with `curl -N`); call out Tomcat behavior in
the docs if needed.
-- **Thread leak on client disconnect.** A subscriber that never reads will pin
a queue. Mitigation: per-subscriber bounded queue + a "no read in N heartbeats
⇒ evict" timer.
-- **Coupling with TODO-67 (observability).** If TODO-67 introduces
`X-Request-Id` propagation and broadcaster subscriptions are keyed by request
id, the two need to align on the id source. Recommend
`SseBroadcaster.subscribe(String id)` accepts any string — id source is the
caller's concern.
-- **Memory pressure under broadcast storms.** A 10k-subscriber broadcaster
with 1024-event queues each can balloon to 10M events × event size. Document;
configurable bound.
-
-## Related work
-
-- `todo/FINISHED-46-juneau-marshall-sse.md` — the marshalling-side SSE
primitives this TODO consumes.
-- `todo/FINISHED-31-inject-aware-microservice.md` — `WritableBeanStore`
auto-wiring for `@Bean SseBroadcaster`.
-- `todo/FINISHED-33-dynamic-rest-children.md` — useful for mounting SSE demo /
metrics-stream resources at runtime.
-- `todo/TODO-67-observability-micrometer-otel.md` (sibling) — `X-Request-Id`
is the natural broadcaster-subscription key.
-- `todo/TODO-70-async-completablefuture-virtual-threads.md` (sibling) —
`Publisher<SseEvent>` return-type support lives there, not here.
diff --git a/todo/TODO.md b/todo/TODO.md
index 669e0748e6..a98604bef7 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -6,8 +6,6 @@
- [TODO-37] - Agent instruction consolidation.
-- [TODO-62] Server-side SSE helpers (broadcaster, per-event flush, heartbeat).
See `todo/TODO-62-sse-server-helpers.md`.
-
- [TODO-63] OpenAPI 3.1 emission + bundled Swagger UI / Redoc auto-mount. See
`todo/TODO-63-openapi-3.1-emission.md`.
- [TODO-64] Conditional-GET / ETag / `If-Modified-Since` helpers on
`RestResponse`. See `todo/TODO-64-etag-conditional-get-helpers.md`.