diff --git a/docs/asciidoc/server-sent-event.adoc b/docs/asciidoc/server-sent-event.adoc
index d9ec23a4fb..be98802874 100644
--- a/docs/asciidoc/server-sent-event.adoc
+++ b/docs/asciidoc/server-sent-event.adoc
@@ -7,7 +7,7 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent
----
{
sse("/sse", sse -> { // <1>
- sse.send("Welcome"); // <2>
+ sse.send("Welcome jooby"); // <2>
});
}
----
@@ -17,7 +17,7 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent
----
{
sse("/sse") { // <1>
- sse.send("Welcome") // <2>
+ sse.send("Welcome jooby") // <2>
}
}
----
@@ -25,6 +25,54 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent
<1> Connection established.
<2> Send a message to the client.
+==== MVC Controllers
+
+Use `@SSE` to register an SSE route from a controller:
+
+.Java
+[source,java,role="primary"]
+----
+import io.jooby.ServerSentEmitter;
+import io.jooby.annotation.SSE;
+
+public class Events {
+ @SSE("/sse")
+ public void sse(ServerSentEmitter emitter) {
+ emitter.send("Welcome");
+ emitter.onClose(() -> {
+ // Clean up resources.
+ });
+ emitter.close();
+ }
+}
+----
+
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+import io.jooby.ServerSentEmitter
+import io.jooby.annotation.SSE
+
+class Events {
+ @SSE("/sse")
+ fun sse(emitter: ServerSentEmitter) {
+ emitter.send("Welcome")
+ emitter.onClose {
+ // Clean up resources.
+ }
+ emitter.close()
+ }
+}
+----
+
+Install the controller with `mvc(new Events())`. The annotation processor generates an
+`app.sse("/sse", this::sse)` registration. In Kotlin, use `mvc(Events())` with KAPT enabled.
+The controller method must return `void` in Java or `Unit` in Kotlin. Kotlin `suspend`
+functions are not supported for `@SSE` methods.
+The emitter is injected directly; regular MVC parameters and class-level `@Path` prefixes
+are supported. Use `@SSE(path = "/sse")` (`@SSE(path = ["/sse"])` in Kotlin) or multiple
+path values as with other MVC routes.
+
==== Message Options
Additional message properties (like custom events, IDs, and retry timeouts) are available via the javadoc:ServerSentMessage[] class:
diff --git a/jooby/src/main/java/io/jooby/annotation/SSE.java b/jooby/src/main/java/io/jooby/annotation/SSE.java
new file mode 100644
index 0000000000..6dd5161d01
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/annotation/SSE.java
@@ -0,0 +1,38 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Server-sent event route for MVC controllers. The method must return {@code void} and may receive
+ * a {@link io.jooby.ServerSentEmitter} alongside regular MVC parameters.
+ *
+ *
{@code
+ * @SSE("/sse")
+ * public void sse(ServerSentEmitter emitter) {
+ * emitter.send("Welcome");
+ * emitter.onClose(() -> {});
+ * emitter.close();
+ * }
+ * }
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface SSE {
+ /**
+ * @return Path patterns. Shortcut for {@link #path()}.
+ */
+ String[] value() default {};
+
+ /**
+ * @return Path patterns.
+ */
+ String[] path() default {};
+}
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java
index 6cefe3a664..f8e83304c0 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java
@@ -23,6 +23,7 @@ public enum HttpMethod implements AnnotationSupport {
TRACE("io.jooby.annotation"),
DELETE,
GET,
+ SSE("io.jooby.annotation"),
HEAD,
OPTIONS,
PATCH,
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java
index 83d7a9b019..59aadf09b5 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java
@@ -32,6 +32,13 @@ public RestRoute(RestRouter router, ExecutableElement method, TypeElement httpMe
super(router, method);
this.httpMethodAnnotation = httpMethodAnnotation;
this.generatedName = method.getSimpleName().toString();
+ if (isSse() && (!returnType.isVoid() || isSuspendFun())) {
+ context.error("@SSE method must return void and must not be suspend: %s", method);
+ }
+ }
+
+ private boolean isSse() {
+ return httpMethodAnnotation.getQualifiedName().contentEquals("io.jooby.annotation.SSE");
}
public String getGeneratedName() {
@@ -196,7 +203,10 @@ public List generateHandlerCall(boolean kt) {
int controllerIndent = 2;
for (var parameter : getParameters(true)) {
- var generatedParameter = parameter.generateMapping(kt);
+ var generatedParameter =
+ isSse() && parameter.getType().is("io.jooby.ServerSentEmitter")
+ ? "emitter"
+ : parameter.generateMapping(kt);
if (parameter.isRequireBeanValidation()) {
generatedParameter =
CodeBlock.of(
@@ -208,7 +218,13 @@ public List generateHandlerCall(boolean kt) {
buffer.add(
statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt)));
- if (returnType.isVoid()) {
+ if (isSse()) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ makeCall(kt, paramList.toString(), false, false),
+ semicolon(kt)));
+ } else if (returnType.isVoid()) {
String statusCode =
httpMethodAnnotation.getSimpleName().toString().equals("DELETE") ? "NO_CONTENT" : "OK";
buffer.add(
@@ -274,6 +290,22 @@ private void methodCallHeader(
String returnTypeGenerics,
String returnTypeString,
boolean throwsException) {
+ if (isSse()) {
+ if (kt) {
+ if (throwsException) buffer.add(statement("@Throws(Exception::class)"));
+ buffer.add(statement("fun ", methodName, "(emitter: io.jooby.ServerSentEmitter) {"));
+ buffer.add(statement(indent(2), "val ctx = emitter.getContext()"));
+ } else {
+ buffer.add(
+ statement(
+ "public void ",
+ methodName,
+ "(io.jooby.ServerSentEmitter emitter)",
+ throwsException ? " throws Exception {" : " {"));
+ buffer.add(statement(indent(2), "var ctx = emitter.getContext();"));
+ }
+ return;
+ }
if (kt) {
if (throwsException) buffer.add(statement("@Throws(Exception::class)"));
diff --git a/modules/jooby-apt/src/test/java/tests/sse/SseController.java b/modules/jooby-apt/src/test/java/tests/sse/SseController.java
new file mode 100644
index 0000000000..4c76375482
--- /dev/null
+++ b/modules/jooby-apt/src/test/java/tests/sse/SseController.java
@@ -0,0 +1,28 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package tests.sse;
+
+import io.jooby.Context;
+import io.jooby.ServerSentEmitter;
+import io.jooby.annotation.Path;
+import io.jooby.annotation.PathParam;
+import io.jooby.annotation.SSE;
+
+@Path("/events")
+public class SseController {
+ @SSE({"/sse", "/alias"})
+ public void sse(ServerSentEmitter emitter) {
+ emitter.send("Welcome");
+ emitter.onClose(() -> {});
+ emitter.close();
+ }
+
+ @SSE(path = "/{name}")
+ public void named(Context ctx, @PathParam String name, ServerSentEmitter emitter)
+ throws Exception {
+ emitter.send(name + ":" + ctx.getRequestPath());
+ }
+}
diff --git a/modules/jooby-apt/src/test/java/tests/sse/SseTest.java b/modules/jooby-apt/src/test/java/tests/sse/SseTest.java
new file mode 100644
index 0000000000..4932e7fe33
--- /dev/null
+++ b/modules/jooby-apt/src/test/java/tests/sse/SseTest.java
@@ -0,0 +1,99 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package tests.sse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import com.google.testing.compile.CompilationSubject;
+import com.google.testing.compile.Compiler;
+import com.google.testing.compile.JavaFileObjects;
+import io.jooby.Context;
+import io.jooby.ServerSentEmitter;
+import io.jooby.StatusCode;
+import io.jooby.apt.JoobyProcessor;
+import io.jooby.apt.ProcessorRunner;
+import io.jooby.value.Value;
+import io.jooby.value.ValueFactory;
+import org.junit.jupiter.api.Test;
+
+public class SseTest {
+ @Test
+ public void shouldGenerateSseRoutesAndInvokeEmitter() throws Exception {
+ new ProcessorRunner(new SseController())
+ .withSourceCode(
+ source -> {
+ assertThat(source).contains("app.sse(\"/events/sse\", this::sse)");
+ assertThat(source).contains("app.sse(\"/events/alias\", this::sse)");
+ assertThat(source).contains("app.sse(\"/events/{name}\", this::named)");
+ assertThat(source).contains("public void sse(io.jooby.ServerSentEmitter emitter)");
+ assertThat(source).doesNotContain("return ctx.getResponseCode()");
+ })
+ .withRouter(
+ app -> {
+ assertThat(app.getRoutes()).hasSize(3);
+ var route = app.getRoutes().getFirst();
+ assertThat(route.getMethod()).isEqualTo("SSE");
+ var ctx = mock(Context.class, RETURNS_SELF);
+ var emitter = mock(ServerSentEmitter.class);
+ when(emitter.getContext()).thenReturn(ctx);
+ doAnswer(
+ call -> {
+ call.getArgument(0, ServerSentEmitter.Handler.class).handle(emitter);
+ return ctx;
+ })
+ .when(ctx)
+ .upgrade(any(ServerSentEmitter.Handler.class));
+
+ route.getHandler().apply(ctx);
+
+ verify(ctx).setResponseType("text/event-stream; charset=utf-8");
+ verify(emitter).send("Welcome");
+ verify(emitter).onClose(any());
+ verify(emitter).close();
+ verify(ctx).setResponseCode(StatusCode.OK);
+
+ when(ctx.path("name")).thenReturn(Value.value(new ValueFactory(), "name", "Ada"));
+ when(ctx.getRequestPath()).thenReturn("/events/Ada");
+ app.getRoutes().get(2).getHandler().apply(ctx);
+ verify(emitter).send("Ada:/events/Ada");
+ });
+ }
+
+ @Test
+ public void shouldGenerateKotlinEmitterHandler() throws Exception {
+ new ProcessorRunner(new SseController())
+ .withSourceCode(
+ true,
+ source -> {
+ assertThat(source).contains("app.sse(\"/events/sse\", this::sse)");
+ assertThat(source).contains("fun sse(emitter: io.jooby.ServerSentEmitter)");
+ assertThat(source).contains("val ctx = emitter.getContext()");
+ });
+ }
+
+ @Test
+ public void shouldRejectNonVoidSseMethods() {
+ var compilation =
+ Compiler.javac()
+ .withProcessors(new JoobyProcessor())
+ .compile(
+ JavaFileObjects.forSourceString(
+ "InvalidSse",
+ """
+ import io.jooby.annotation.SSE;
+ public class InvalidSse {
+ @SSE("/sse")
+ public String sse(io.jooby.ServerSentEmitter emitter) {
+ return "invalid";
+ }
+ }
+ """));
+ CompilationSubject.assertThat(compilation).failed();
+ CompilationSubject.assertThat(compilation).hadErrorContaining("@SSE method must return void");
+ }
+}
diff --git a/tests/src/test/kotlin/io/jooby/KotlinSseTest.kt b/tests/src/test/kotlin/io/jooby/KotlinSseTest.kt
new file mode 100644
index 0000000000..2746393064
--- /dev/null
+++ b/tests/src/test/kotlin/io/jooby/KotlinSseTest.kt
@@ -0,0 +1,46 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby
+
+import io.jooby.internal.mvc.KotlinSseController_
+import io.jooby.value.Value
+import io.jooby.value.ValueFactory
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.*
+
+class KotlinSseTest {
+ @Test
+ fun shouldSupportKotlinSseControllers() {
+
+ val app = Jooby().apply { mvc(KotlinSseController_()) }
+
+ assertEquals(
+ listOf("/kotlin-sse/events", "/kotlin-sse/alias", "/kotlin-sse/{name}"),
+ app.routes.map { it.pattern })
+
+ val ctx = mock(Context::class.java, RETURNS_SELF)
+ val emitter = mock(ServerSentEmitter::class.java)
+ `when`(emitter.context).thenReturn(ctx)
+ `when`(ctx.path("name")).thenReturn(Value.value(ValueFactory(), "name", "Ada"))
+ `when`(ctx.requestPath).thenReturn("/kotlin-sse/Ada")
+ doAnswer { call ->
+ call.getArgument(0).handle(emitter)
+ ctx
+ }.`when`(ctx).upgrade(any(ServerSentEmitter.Handler::class.java))
+
+ app.routes.forEach { route ->
+ assertEquals("SSE", route.method)
+ route.handler.apply(ctx)
+ }
+
+ verify(emitter, times(2)).send("Welcome")
+ verify(emitter).send("Ada:/kotlin-sse/Ada")
+ verify(emitter, times(3)).close()
+ verify(ctx, times(3)).setResponseType("text/event-stream; charset=utf-8")
+ }
+}
diff --git a/tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt b/tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt
new file mode 100644
index 0000000000..299b958fe2
--- /dev/null
+++ b/tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt
@@ -0,0 +1,29 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.mvc
+
+import io.jooby.Context
+import io.jooby.ServerSentEmitter
+import io.jooby.annotation.Path
+import io.jooby.annotation.PathParam
+import io.jooby.annotation.SSE
+
+@Path("/kotlin-sse")
+class KotlinSseController {
+
+ @SSE("/events", "/alias")
+ fun events(emitter: ServerSentEmitter) {
+ emitter.send("Welcome")
+ emitter.close()
+ }
+
+ @SSE(path = ["/{name}"])
+ fun named(ctx: Context, @PathParam name: String, emitter: ServerSentEmitter): Unit {
+ emitter.send("$name:${ctx.requestPath}")
+ emitter.close()
+ }
+
+}