Repository: goadesign/goa
Pull request #3697 (2025-04-12 22:17:50)

SSE Support
This PR implements the proposal described in #3696

Done:
- [x] DSL and expressions
- [x] Response rendering
- [x] Handling of Last-Request-Id server side
- [x] Client side


Patch:
diff --git a/.gitignore b/.gitignore
index 2119f65e..5d07e62b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,6 @@ cmd/goa/goa
 
 # DeepSource cruft
 cover.out
-bin/
+
+# MacOS cruft
+**/.DS_Store
diff --git a/dsl/sse.go b/dsl/sse.go
new file mode 100644
index 00000000..7d47684d
--- /dev/null
+++ b/dsl/sse.go
@@ -0,0 +1,300 @@
+package dsl
+
+import (
+	"goa.design/goa/v3/eval"
+	"goa.design/goa/v3/expr"
+)
+
+// ServerSentEvents specifies that a streaming endpoint should use the
+// Server-Sent Events protocol for streaming instead of WebSockets. It can be
+// used in four ways:
+//
+//  1. ServerSentEvents(): StreamingResult type is used directly as the event
+//     "data" field (serialized into JSON if not a primitive type)
+//  2. ServerSentEvents("attributeName"): The specified attribute is used as the
+//     event "data" field (serialized into JSON if not a primitive type)
+//  3. ServerSentEvents(func() { ... }): Custom mapping of attributes to event
+//     fields
+//  4. ServerSentEvents("attributeName", func() { ... }): Define attribute name
+//     used as the "data" field and custom mapping for others.
+//
+// ServerSentEvents can appear in an API HTTP expression (to specify SSE for all streaming
+// methods in the API), in a Service HTTP expression (to specify SSE for all streaming
+// methods in the service), or in a Method HTTP expression. When specified at the
+// API or service level, any method with a StreamingPayload will fall back to using WebSockets
+// as SSE only supports server-to-client streaming.
+//
+// See SSEEventData, SSEEventID, SSEEventType, SSEEventRetry for more details on
+// mapping result attributes to event fields. See SSERequestID for more details on
+// mapping payload attributes to the Last-Event-ID request header.
+//
+// Example:
+//
+//	var Notification = Type("Notification", func() {
+//	    Attribute("message", String)
+//	    Attribute("timestamp", String)
+//	    Required("message", "timestamp")
+//	})
+//
+//	var _ = Service("events", func() {
+//	    HTTP(func() {
+//	        ServerSentEvents() // All streaming methods in this service use SSE by default
+//	    })
+//
+//	    // Simple method with just data field
+//	    Method("stream", func() {
+//	        StreamingResult(Notification)
+//	        HTTP(func() {
+//	            GET("/events") // Messages are sent as {"data": {"message": <message>, "timestamp": <timestamp>}}
+//	        })
+//	    })
+//	})
+//
+//	var _ = Service("other", func() {
+//	    // Method using WebSockets
+//	    Method("stream", func() {
+//	        StreamingResult(Notification)
+//	        HTTP(func() {
+//	            GET("/websocket")
+//	        })
+//	    })
+//
+//	    // Method using SSE
+//	    Method("stream", func() {
+//	        Payload(func() {
+//	            Attribute("id", String)
+//	        })
+//	        StreamingResult(Notification)
+//	        HTTP(func() {
+//	            ServerSentEvents(func() {   // Use SSE for this method
+//	                SSERequestID("id")      // Use payload "id" field to set "Last-Event-Id" request header
+//	                SSEEventID("timestamp") // Use result "timestamp" attribute for "id" event field
+//	                SSEEventData("message") // Use result "message" attribute for "data" event field
+//	            })
+//	            GET("/sse") // Messages are sent as {"id": <timestamp>, "data": <message>}
+//	        })
+//	    })
+//	})
+func ServerSentEvents(args ...any) {
+	if len(args) > 2 {
+		eval.TooManyArgError()
+		return
+	}
+
+	var fn func()
+	var dataField string
+	if len(args) > 0 {
+		switch actual := args[0].(type) {
+		case func():
+			fn = actual
+		case string:
+			dataField = actual
+		case nil:
+			// Use the entire result as data field
+		default:
+			eval.InvalidArgError("function or string", args[0])
+			return
+		}
+		if len(args) == 2 {
+			if fn != nil {
+				eval.TooManyArgError()
+				return
+			}
+			var ok bool
+			fn, ok = args[1].(func())
+			if !ok {
+				eval.InvalidArgError("function", args[1])
+				return
+			}
+		}
+	}
+
+	sse := &expr.HTTPSSEExpr{
+		DataField: dataField,
+	}
+
+	switch actual := eval.Current().(type) {
+	case *expr.HTTPExpr:
+		actual.SSE = sse
+	case *expr.HTTPServiceExpr:
+		actual.SSE = sse
+	case *expr.HTTPEndpointExpr:
+		actual.SSE = sse
+	default:
+		eval.IncompatibleDSL()
+	}
+
+	if fn != nil {
+		eval.Execute(fn, sse)
+	}
+}
+
+// SSERequestID defines the attribute of the Payload type that provides the
+// Last-Event-ID request header value. The attribute must exist in the Payload
+// type and must be of type String.
+//
+// SSERequestID must appear in a `ServerSentEvents` expression.
+//
+// SSERequestID accepts a single argument: the name of the attribute of the
+// Payload type that provides the Last-Event-ID request header value.
+//
+// Example:
+//
+//	Method("stream", func() {
+//	    Payload(func() {
+//	        Attribute("id", String)
+//	    })
+//	    StreamingResult(Notification)
+//	    HTTP(func() {
+//	        GET("/events")
+//	        ServerSentEvents(func() {   // Use SSE for this method
+//	            SSERequestID("id")      // Use payload "id" field to set "Last-Event-Id" request header
+//	            SSEEventID("timestamp") // Use result "timestamp" attribute for "id" event field
+//	            SSEEventData("message") // Use result "message" attribute for "data" event field
+//	        })
+//	    })
+//	})
+func SSERequestID(name string) {
+	if name == "" {
+		eval.ReportError("request ID field name cannot be empty")
+		return
+	}
+	sse, ok := eval.Current().(*expr.HTTPSSEExpr)
+	if !ok {
+		eval.IncompatibleDSL()
+		return
+	}
+	sse.RequestIDField = name
+}
+
+// SSEEventData defines the attribute of the StreamingResult type that provides the
+// data field for a Server-Sent Event. The attribute must exist in the
+// StreamingResult type.
+//
+// SSEEventData must appear in a `ServerSentEvents` expression.
+//
+// SSEEventData accepts a single argument: the name of the attribute of the
+// StreamingResult type that provides the data field for a Server-Sent Event.
+//
+// Example:
+//
+//	Method("stream", func() {
+//	    StreamingResult(Payload)
+//	    HTTP(func() {
+//	        GET("/events")
+//	        ServerSentEvents(func() {
+//	            SSEEventData("message") // Use payload "message" attribute for SSE data field, other attributes are ignored
+//	        })
+//	    })
+//	})
+func SSEEventData(name string) {
+	if name == "" {
+		eval.ReportError("data field name cannot be empty")
+		return
+	}
+	sse, ok := eval.Current().(*expr.HTTPSSEExpr)
+	if !ok {
+		eval.IncompatibleDSL()
+		return
+	}
+	sse.DataField = name
+}
+
+// SSEEventID defines the attribute of the StreamingResult type that provides the
+// id field for a Server-Sent Event. The attribute must exist in the
+// StreamingResult type and must be of type String.
+//
+// SSEEventID must appear in a `ServerSentEvents` expression.
+//
+// SSEEventID accepts a single argument: the name of the attribute of the
+// StreamingResult type that provides the id field for a Server-Sent Event.
+//
+// Example:
+//
+//	Method("stream", func() {
+//	    StreamingResult(Payload)
+//	    HTTP(func() {
+//	        GET("/events")
+//	        ServerSentEvents(func() {
+//	            SSEEventID("timestamp") // Use "timestamp" attribute for SSE id field
+//	        })
+//	    })
+//	})
+func SSEEventID(name string) {
+	if name == "" {
+		eval.ReportError("id field name cannot be empty")
+		return
+	}
+	sse, ok := eval.Current().(*expr.HTTPSSEExpr)
+	if !ok {
+		eval.IncompatibleDSL()
+		return
+	}
+	sse.IDField = name
+}
+
+// SSEEventType defines the attribute of the StreamingResult type that provides the
+// event field (event type) for a Server-Sent Event. The attribute must exist in the
+// StreamingResult type and must be of type String.
+//
+// SSEEventType must appear in a `ServerSentEvents` expression.
+//
+// SSEEventType accepts a single argument: the name of the attribute of the
+// StreamingResult type that provides the event field for a Server-Sent Event.
+//
+// Example:
+//
+//	Method("stream", func() {
+//	    StreamingResult(Payload)
+//	    HTTP(func() {
+//	        GET("/events")
+//	        ServerSentEvents(func() {
+//	            SSEEventType("type") // Use payload "type" attribute for SSE event field
+//	        })
+//	    })
+//	})
+func SSEEventType(name string) {
+	if name == "" {
+		eval.ReportError("event field name cannot be empty")
+		return
+	}
+	sse, ok := eval.Current().(*expr.HTTPSSEExpr)
+	if !ok {
+		eval.IncompatibleDSL()
+		return
+	}
+	sse.EventField = name
+}
+
+// SSEEventRetry defines the attribute of the StreamingResult type that provides
+// the retry field for a Server-Sent Event. The attribute must exist in the
+// StreamingResult type and must be of type Int or UInt.
+//
+// SSEEventRetry must appear in a `ServerSentEvents` expression.
+//
+// SSEEventRetry accepts a single argument: the name of the attribute of the
+// StreamingResult type that provides the retry field for a Server-Sent Event.
+//
+// Example:
+//
+//	Method("stream", func() {
+//	    StreamingResult(Notification)
+//	    HTTP(func() {
+//	        GET("/events")
+//	        ServerSentEvents(func() {
+//	            SSEEventRetry("retry") // Use "retry" attribute for SSE retry field
+//	        })
+//	    })
+//	})
+func SSEEventRetry(name string) {
+	if name == "" {
+		eval.ReportError("retry field name cannot be empty")
+		return
+	}
+	sse, ok := eval.Current().(*expr.HTTPSSEExpr)
+	if !ok {
+		eval.IncompatibleDSL()
+		return
+	}
+	sse.RetryField = name
+}
diff --git a/expr/http.go b/expr/http.go
index 557adb64..c59b0330 100644
--- a/expr/http.go
+++ b/expr/http.go
@@ -29,6 +29,9 @@ type (
 		Services []*HTTPServiceExpr
 		// Errors lists the error HTTP responses.
 		Errors []*HTTPErrorExpr
+		// SSE contains the Server-Sent Events configuration for all
+		// streaming endpoints in the API.
+		SSE *HTTPSSEExpr
 	}
 )
 
diff --git a/expr/http_endpoint.go b/expr/http_endpoint.go
index bc6c92a6..4ce5fdba 100644
--- a/expr/http_endpoint.go
+++ b/expr/http_endpoint.go
@@ -1,6 +1,7 @@
 package expr
 
 import (
+	"errors"
 	"fmt"
 	"path"
 	"strings"
@@ -66,6 +67,10 @@ type (
 		MultipartRequest bool
 		// Redirect defines a redirect for the endpoint.
 		Redirect *HTTPRedirectExpr
+		// SSE defines the Server-Sent Events configuration for this endpoint if it's
+		// a streaming endpoint. If nil, the endpoint uses WebSockets by default or
+		// inherits the service-level SSE configuration if defined.
+		SSE *HTTPSSEExpr
 		// Meta is a set of key/value pairs with semantic that is
 		// specific to each generator, see dsl.Meta.
 		Meta MetaExpr
@@ -274,6 +279,15 @@ func (e *HTTPEndpointExpr) Prepare() {
 		e.Responses = []*HTTPResponseExpr{{StatusCode: status}}
 	}
 
+	// Inherit SSE configuration from service or API level for streaming endpoints
+	if e.MethodExpr.Stream == ServerStreamKind && e.SSE == nil {
+		if e.Service.SSE != nil {
+			e.SSE = e.Service.SSE
+		} else if Root.API.HTTP.SSE != nil {
+			e.SSE = Root.API.HTTP.SSE
+		}
+	}
+
 	// Error -> ResponseError
 	methodErrors := map[string]struct{}{}
 	for _, v := range e.HTTPErrors {
@@ -377,6 +391,29 @@ func (e *HTTPEndpointExpr) Validate() error {
 		}
 	}
 
+	// Validate streaming endpoints for SSE compatibility
+	if e.MethodExpr.Stream == ServerStreamKind {
+		// Prepare already handles inheriting SSE from service or API level
+		if e.SSE != nil {
+			if err := e.SSE.Validate(e); err != nil {
+				var valErr *eval.ValidationErrors
+				if errors.As(err, &valErr) {
+					verr.Merge(valErr)
+				}
+			}
+		}
+	} else if e.SSE != nil {
+		// Error if SSE is defined but endpoint is not server streaming
+		switch e.MethodExpr.Stream {
+		case BidirectionalStreamKind:
+			verr.Add(e, "Server-Sent Events cannot be used with bidirectional streaming endpoints")
+		case ClientStreamKind:
+			verr.Add(e, "Server-Sent Events cannot be used with client-to-server streaming endpoints")
+		default:
+			verr.Add(e, "Server-Sent Events can only be used with endpoints that have a streaming result")
+		}
+	}
+
 	// Redirect is not compatible with Response.
 	if e.Redirect != nil {
 		found := false
@@ -643,10 +680,13 @@ func (e *HTTPEndpointExpr) Validate() error {
 	if e.SkipRequestBodyEncodeDecode && body.Type != Empty {
 		verr.Add(e, "HTTP endpoint request body must be empty when using SkipRequestBodyEncodeDecode but not all method payload attributes are mapped to headers and params. Make sure to define Headers and Params as needed.")
 	}
+	// For streaming endpoints, check if request body is allowed
 	if e.MethodExpr.IsStreaming() && body.Type != Empty {
-		// Refer Websocket protocol - https://tools.ietf.org/html/rfc6455
-		// Protocol does not allow HTTP request body to be passed.
-		verr.Add(e, "HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params.")
+		// SSE endpoints can have request bodies, but WebSocket endpoints cannot
+		// Refer WebSocket protocol - https://tools.ietf.org/html/rfc6455
+		if e.SSE == nil { // Only apply this validation to non-SSE streaming endpoints
+			verr.Add(e, "HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params.")
+		}
 	}
 
 	return verr
diff --git a/expr/http_service.go b/expr/http_service.go
index 1944ed92..1e1a1799 100644
--- a/expr/http_service.go
+++ b/expr/http_service.go
@@ -40,6 +40,9 @@ type (
 		HTTPErrors []*HTTPErrorExpr
 		// FileServers is the list of static asset serving endpoints
 		FileServers []*HTTPFileServerExpr
+		// SSE defines the Server-Sent Events configuration for all streaming endpoints
+		// in this service. If nil, streaming endpoints use WebSockets by default.
+		SSE *HTTPSSEExpr
 		// Meta is a set of key/value pairs with semantic that is
 		// specific to each generator.
 		Meta MetaExpr
diff --git a/expr/http_sse.go b/expr/http_sse.go
new file mode 100644
index 00000000..3b7ddfcd
--- /dev/null
+++ b/expr/http_sse.go
@@ -0,0 +1,103 @@
+package expr
+
+import (
+	"fmt"
+	"strings"
+
+	"slices"
+
+	"goa.design/goa/v3/eval"
+)
+
+type (
+	// HTTPSSEExpr describes a Server-Sent Events configuration for a HTTP endpoint.
+	// It defines how a streaming endpoint should use the Server-Sent Events protocol
+	// instead of WebSockets.
+	HTTPSSEExpr struct {
+		// RequestIDField is the name of the attribute in the Payload type
+		// that provides the Last-Event-ID request header value.
+		// If empty, no Last-Event-ID request header is included in the request.
+		RequestIDField string
+		// DataField is the name of the attribute in the StreamingResult type
+		// that provides the data field for a Server-Sent Event.
+		// If empty, the entire StreamingResult is used as the data field.
+		DataField string
+		// IDField is the name of the attribute in the StreamingResult type
+		// that provides the id field for a Server-Sent Event.
+		// If empty, no id field is included in the event.
+		IDField string
+		// EventField is the name of the attribute in the StreamingResult type
+		// that provides the event field (event type) for a Server-Sent Event.
+		// If empty, no event field is included in the event.
+		EventField string
+		// RetryField is the name of the attribute in the StreamingResult type
+		// that provides the retry field for a Server-Sent Event.
+		// If empty, no retry field is included in the event.
+		RetryField string
+	}
+)
+
+// EvalName returns the generic expression name used in error messages.
+func (e *HTTPSSEExpr) EvalName() string {
+	return "Server-Sent Events"
+}
+
+// Validate validates the Server-Sent Events expression against a specific result type.
+func (e *HTTPSSEExpr) Validate(endpoint *HTTPEndpointExpr) error {
+	if endpoint == nil || endpoint.MethodExpr == nil || endpoint.MethodExpr.Result == nil {
+		return nil
+	}
+
+	verr := new(eval.ValidationErrors)
+	if err := validateSSEField(endpoint.MethodExpr.Payload, e.RequestIDField, "request ID", []DataType{String}); err != nil {
+		verr.Add(endpoint, err.Error())
+	}
+	if err := validateSSEField(endpoint.MethodExpr.Result, e.DataField, "event data", nil); err != nil {
+		verr.Add(endpoint, err.Error())
+	}
+	if err := validateSSEField(endpoint.MethodExpr.Result, e.IDField, "event id", []DataType{String}); err != nil {
+		verr.Add(endpoint, err.Error())
+	}
+	if err := validateSSEField(endpoint.MethodExpr.Result, e.EventField, "event type", []DataType{String}); err != nil {
+		verr.Add(endpoint, err.Error())
+	}
+	if err := validateSSEField(endpoint.MethodExpr.Result, e.RetryField, "event retry", []DataType{Int, Int32, Int64, UInt, UInt32, UInt64}); err != nil {
+		verr.Add(endpoint, err.Error())
+	}
+
+	if len(verr.Errors) == 0 {
+		return nil
+	}
+	return verr
+}
+
+// validateSSEField validates that the given field exists in the result type and has the expected type.
+func validateSSEField(rt *AttributeExpr, field, desc string, expectedTypes []DataType) error {
+	if field == "" {
+		return nil
+	}
+
+	if rt == nil {
+		return fmt.Errorf("cannot use %q for SSE %s field: result type is nil", field, desc)
+	}
+
+	obj := AsObject(rt.Type)
+	if obj == nil {
+		return fmt.Errorf("cannot use %q for SSE %s field: result type is not an object", field, desc)
+	}
+
+	att := obj.Attribute(field)
+	if att == nil {
+		return fmt.Errorf("cannot use %q for SSE %s field: attribute not found in result type", field, desc)
+	}
+
+	if len(expectedTypes) > 0 && !slices.Contains(expectedTypes, att.Type) {
+		typeNames := make([]string, len(expectedTypes))
+		for i, t := range expectedTypes {
+			typeNames[i] = t.Name()
+		}
+		return fmt.Errorf("cannot use %q for SSE %s field: attribute type must be one of %s", field, desc, strings.Join(typeNames, ", "))
+	}
+
+	return nil
+}
diff --git a/http/codegen/client.go b/http/codegen/client.go
index 5d70b2ed..14e68654 100644
--- a/http/codegen/client.go
+++ b/http/codegen/client.go
@@ -17,6 +17,9 @@ func ClientFiles(genpkg string, root *expr.RootExpr) []*codegen.File {
 		if f := websocketClientFile(genpkg, svc); f != nil {
 			files = append(files, f)
 		}
+		if f := sseClientFile(genpkg, svc); f != nil {
+			files = append(files, f)
+		}
 	}
 	for _, svc := range root.API.HTTP.Services {
 		if f := clientEncodeDecodeFile(genpkg, svc); f != nil {
@@ -50,10 +53,13 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr) *codegen.File {
 		}),
 	}
 	sections = append(sections, &codegen.SectionTemplate{
-		Name:    "client-struct",
-		Source:  readTemplate("client_struct"),
-		Data:    data,
-		FuncMap: map[string]any{"hasWebSocket": hasWebSocket},
+		Name:   "client-struct",
+		Source: readTemplate("client_struct"),
+		Data:   data,
+		FuncMap: map[string]any{
+			"hasWebSocket": hasWebSocket,
+			"hasSSE":       hasSSE,
+		},
 	})
 
 	for _, e := range data.Endpoints {
@@ -67,10 +73,13 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr) *codegen.File {
 	}
 
 	sections = append(sections, &codegen.SectionTemplate{
-		Name:    "http-client-init",
-		Source:  readTemplate("client_init"),
-		Data:    data,
-		FuncMap: map[string]any{"hasWebSocket": hasWebSocket},
+		Name:   "http-client-init",
+		Source: readTemplate("client_init"),
+		Data:   data,
+		FuncMap: map[string]any{
+			"hasWebSocket": hasWebSocket,
+			"hasSSE":       hasSSE,
+		},
 	})
 
 	for _, e := range data.Endpoints {
@@ -80,6 +89,7 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr) *codegen.File {
 			Data:   e,
 			FuncMap: map[string]any{
 				"isWebSocketEndpoint": isWebSocketEndpoint,
+				"isSSEEndpoint":       isSSEEndpoint,
 				"responseStructPkg":   responseStructPkg,
 			},
 		})
diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go
index da4fa238..ad85e35d 100644
--- a/http/codegen/client_cli.go
+++ b/http/codegen/client_cli.go
@@ -14,8 +14,8 @@ type commandData struct {
 	*cli.CommandData
 	// Subcommands is the list of endpoint commands.
 	Subcommands []*subcommandData
-	// NeedStream if true initializes the websocket dialer.
-	NeedStream bool
+	// NeedDialer if true initializes the websocket dialer.
+	NeedDialer bool
 }
 
 // commandData wraps the common SubcommandData and adds HTTP-specific fields.
@@ -50,7 +50,7 @@ func ClientCLIFiles(genpkg string, root *expr.RootExpr) []*codegen.File {
 		if len(sd.Endpoints) > 0 {
 			command := &commandData{
 				CommandData: cli.BuildCommandData(sd.Service),
-				NeedStream:  hasWebSocket(sd),
+				NeedDialer:  hasWebSocket(sd),
 			}
 
 			for _, e := range sd.Endpoints {
@@ -292,7 +292,7 @@ func streamFlag(svcn, en string) *cli.FlagData {
 // uses stream for sending payload/result.
 func streamingCmdExists(data []*commandData) bool {
 	for _, c := range data {
-		if c.NeedStream {
+		if c.NeedDialer {
 			return true
 		}
 	}
diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go
index c6d57c83..937f6067 100644
--- a/http/codegen/example_cli.go
+++ b/http/codegen/example_cli.go
@@ -85,7 +85,7 @@ func exampleCLI(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr) *codeg
 				"Services": svcData,
 			},
 			FuncMap: map[string]any{
-				"needStream": needStream,
+				"needDialer": needDialer,
 			},
 		},
 		{
@@ -96,7 +96,7 @@ func exampleCLI(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr) *codeg
 				"APIPkg":   apiPkg,
 			},
 			FuncMap: map[string]any{
-				"needStream":   needStream,
+				"needDialer":   needDialer,
 				"hasWebSocket": hasWebSocket,
 			},
 		},
diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go
index ffa9835a..829781bd 100644
--- a/http/codegen/example_server.go
+++ b/http/codegen/example_server.go
@@ -105,7 +105,7 @@ func exampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr) *co
 				"Services": svcdata,
 				"APIPkg":   apiPkg,
 			},
-			FuncMap: map[string]any{"needStream": needStream, "hasWebSocket": hasWebSocket},
+			FuncMap: map[string]any{"needDialer": needDialer, "hasWebSocket": hasWebSocket},
 		},
 		{
 			Name:   "server-http-middleware",
diff --git a/http/codegen/server.go b/http/codegen/server.go
index c514baa2..93d8428d 100644
--- a/http/codegen/server.go
+++ b/http/codegen/server.go
@@ -20,6 +20,9 @@ func ServerFiles(genpkg string, root *expr.RootExpr) []*codegen.File {
 		if f := websocketServerFile(genpkg, svc); f != nil {
 			files = append(files, f)
 		}
+		if f := sseServerFile(genpkg, svc); f != nil {
+			files = append(files, f)
+		}
 	}
 	for _, svc := range root.API.HTTP.Services {
 		if f := serverEncodeDecodeFile(genpkg, svc); f != nil {
@@ -39,6 +42,7 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr) *codegen.File {
 		"join":                strings.Join,
 		"hasWebSocket":        hasWebSocket,
 		"isWebSocketEndpoint": isWebSocketEndpoint,
+		"isSSEEndpoint":       isSSEEndpoint,
 		"viewedServerBody":    viewedServerBody,
 		"mustDecodeRequest":   mustDecodeRequest,
 		"addLeadingSlash":     addLeadingSlash,
@@ -266,10 +270,10 @@ func viewedServerBody(sbd []*TypeData, view string) *TypeData {
 }
 
 func addLeadingSlash(s string) string {
-	if strings.HasPrefix(s, "/") {
-		return s
+	if s == "" || s[0] != '/' {
+		return "/" + s
 	}
-	return "/" + s
+	return s
 }
 
 func mapQueryDecodeData(dt expr.DataType, varName string, inc int) map[string]any {
diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go
index 43ef37e9..2d8bf0f0 100644
--- a/http/codegen/service_data.go
+++ b/http/codegen/service_data.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"net/http"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -36,6 +37,7 @@ var (
 					_, ok := dt.(expr.UserType)
 					return ok
 				},
+				"isWebSocketEndpoint": isWebSocketEndpoint,
 			}).
 			Parse(readTemplate("request_init")),
 	)
@@ -152,6 +154,9 @@ type (
 		// ServerWebSocket holds the data to render the server struct which
 		// implements the server stream interface.
 		ServerWebSocket *WebSocketData
+		// SSE holds the data to render the server struct which implements the
+		// server stream interface for SSE.
+		SSE *SSEData
 		// Redirect defines a redirect for the endpoint.
 		Redirect *RedirectData
 
@@ -794,7 +799,7 @@ func (ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData {
 			"Args":         args,
 			"PathInit":     routes[0].PathInit,
 			"Verb":         routes[0].Verb,
-			"IsStreaming":  httpEndpoint.MethodExpr.IsStreaming(),
+			"IsWebSocket":  httpEndpoint.MethodExpr.IsStreaming() && httpEndpoint.SSE == nil,
 		}
 		if httpEndpoint.SkipRequestBodyEncodeDecode {
 			data["RequestStruct"] = pkg + "." + method.RequestStruct
@@ -838,6 +843,7 @@ func (ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData {
 		}
 		if httpEndpoint.MethodExpr.IsStreaming() {
 			initWebSocketData(ed, httpEndpoint, sd)
+			initSSEData(ed, httpEndpoint, sd)
 		}
 
 		if httpEndpoint.MultipartRequest {
@@ -2770,13 +2776,8 @@ func upgradeParams(e *EndpointData, fn string) map[string]any {
 	}
 }
 
-// needStream returns true if at least one method in the defined services
-// uses stream for sending payload/result.
-func needStream(data []*ServiceData) bool {
-	for _, svc := range data {
-		if hasWebSocket(svc) {
-			return true
-		}
-	}
-	return false
+// needDialer returns true if at least one method in the defined services
+// uses WebSocket for sending payload or result.
+func needDialer(data []*ServiceData) bool {
+	return slices.ContainsFunc(data, hasWebSocket)
 }
diff --git a/http/codegen/sse.go b/http/codegen/sse.go
new file mode 100644
index 00000000..9494f1a9
--- /dev/null
+++ b/http/codegen/sse.go
@@ -0,0 +1,191 @@
+package codegen
+
+import (
+	"fmt"
+	"path/filepath"
+
+	"slices"
+
+	"goa.design/goa/v3/codegen"
+	"goa.design/goa/v3/expr"
+)
+
+type (
+	// SSEData contains the data needed to render struct type that
+	// implements the server and client stream interface for SSE.
+	SSEData struct {
+		// StructName is the name of the generated struct which encapsulates the
+		// server implementation.
+		StructName string
+		// Interface is the fully qualified name of the interface that
+		// the struct implements.
+		Interface string
+		// SendName is the name of the send function.
+		SendName string
+		// SendDesc is the description for the send function.
+		SendDesc string
+		// SendWithContextName is the name of the send function with context.
+		SendWithContextName string
+		// SendWithContextDesc is the description for the send function with context.
+		SendWithContextDesc string
+		// RecvName is the name of the client method to connect to the SSE endpoint.
+		RecvName string
+		// RecvDesc is the description for the client method.
+		RecvDesc string
+		// EventTypeRef is the fully qualified type ref for the event type.
+		EventTypeRef string
+		// EventTypeName is the name of the event type without package qualifier.
+		EventTypeName string
+		// EventIsStruct indicates whether the SSE method return type is a struct.
+		EventIsStruct bool
+		// DataFieldTypeRef is the fully qualified type ref for the data field if any.
+		DataFieldTypeRef string
+		// DataField is the name of the result type event data attribute if any.
+		// If empty, the entire result type is used as the data field.
+		DataField string
+		// IDField is the name of the result type event ID attribute if any.
+		// If empty, no id field is included in the event.
+		IDField string
+		// EventField is the name of the result type event field if any.
+		// If empty, no event field is included in the event.
+		EventField string
+		// RetryField is the name of the result type event retry field if any.
+		// If empty, no retry field is included in the event.
+		RetryField string
+		// RequestIDField is the name of the payload field that maps to the Last-Event-ID header if any.
+		// If empty, no last event id is included in the request.
+		RequestIDField string
+	}
+)
+
+// initSSEData initializes the SSE related data in ed.
+func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) {
+	if e.SSE == nil {
+		return
+	}
+
+	md := ed.Method
+	svc := sd.Service
+	sendDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint SSE connection.", md.ServerStream.SendName, ed.Result.Name, md.Name)
+	sendWithContextDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint SSE connection with context.", md.ServerStream.SendWithContextName, ed.Result.Name, md.Name)
+	recvDesc := fmt.Sprintf("%s connects to the %q SSE endpoint and streams events.", md.ServerStream.RecvName, md.Name)
+
+	var dataFieldTypeRef string
+	if e.SSE.DataField != "" {
+		if obj, ok := e.MethodExpr.Result.Type.(*expr.Object); ok {
+			for _, nat := range *obj {
+				if nat.Name == e.SSE.DataField {
+					dataFieldTypeRef = sd.Service.Scope.GoFullTypeRef(nat.Attribute, svc.PkgName)
+					break
+				}
+			}
+		}
+	}
+
+	ed.SSE = &SSEData{
+		StructName:          md.ServerStream.VarName,
+		Interface:           fmt.Sprintf("%s.%s", svc.PkgName, md.ServerStream.Interface),
+		SendName:            md.ServerStream.SendName,
+		SendDesc:            sendDesc,
+		SendWithContextName: md.ServerStream.SendWithContextName,
+		SendWithContextDesc: sendWithContextDesc,
+		RecvName:            md.ClientStream.RecvName,
+		RecvDesc:            recvDesc,
+		EventTypeRef:        ed.Result.Ref,
+		EventTypeName:       ed.Result.Name,
+		EventIsStruct:       ed.Result.IsStruct,
+		DataFieldTypeRef:    dataFieldTypeRef,
+		DataField:           e.SSE.DataField,
+		IDField:             e.SSE.IDField,
+		EventField:          e.SSE.EventField,
+		RetryField:          e.SSE.RetryField,
+		RequestIDField:      e.SSE.RequestIDField,
+	}
+}
+
+// sseServerFile returns the file implementing the SSE server
+// streaming implementation if any.
+func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr) *codegen.File {
+	data := HTTPServices.Get(svc.Name())
+	if data == nil {
+		return nil
+	}
+
+	// Check if any endpoint has SSE
+	hasSSE := false
+	for _, ed := range data.Endpoints {
+		if ed.SSE != nil {
+			hasSSE = true
+			break
+		}
+	}
+	if !hasSSE {
+		return nil
+	}
+
+	path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "server", "sse.go")
+	sections := []*codegen.SectionTemplate{
+		codegen.Header(
+			"sse",
+			"server",
+			[]*codegen.ImportSpec{
+				{Path: "context"},
+				{Path: "io"},
+				{Path: "net/http"},
+				{Path: "sync"},
+				{Path: "time"},
+				{Path: "encoding/json"},
+				{Path: "fmt"},
+				{Path: genpkg + "/" + codegen.SnakeCase(svc.Name())},
+				{Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views"},
+			},
+		),
+	}
+	sections = append(sections, sseTemplateSections(data)...)
+	return &codegen.File{Path: path, SectionTemplates: sections}
+}
+
+// sseTemplateSections returns section templates for SSE endpoints.
+func sseTemplateSections(data *ServiceData) []*codegen.SectionTemplate {
+	sections := make([]*codegen.SectionTemplate, 0)
+	for _, ed := range data.Endpoints {
+		if ed.SSE == nil {
+			continue
+		}
+		// Create a map of template functions needed for the SSE template
+		funcs := map[string]interface{}{
+			"dict": func(values ...any) (map[string]any, error) {
+				if len(values)%2 != 0 {
+					return nil, fmt.Errorf("odd number of arguments")
+				}
+				dict := make(map[string]any, len(values)/2)
+				for i := 0; i < len(values); i += 2 {
+					key, ok := values[i].(string)
+					if !ok {
+						return nil, fmt.Errorf("dict keys must be strings")
+					}
+					dict[key] = values[i+1]
+				}
+				return dict, nil
+			},
+		}
+		sections = append(sections, &codegen.SectionTemplate{
+			Name:    "server-sse",
+			Source:  readTemplate("server_sse", "sse_format"),
+			Data:    ed,
+			FuncMap: funcs,
+		})
+	}
+	return sections
+}
+
+// isSSEEndpoint returns true if the endpoint defines a streaming result
+// with SSE.
+func isSSEEndpoint(ed *EndpointData) bool {
+	return ed.SSE != nil
+}
+
+// hasSSE returns true if at least one endpoint in the service uses SSE.
+func hasSSE(data *ServiceData) bool {
+	return slices.ContainsFunc(data.Endpoints, isSSEEndpoint)
+}
diff --git a/http/codegen/sse_client.go b/http/codegen/sse_client.go
new file mode 100644
index 00000000..5852e932
--- /dev/null
+++ b/http/codegen/sse_client.go
@@ -0,0 +1,85 @@
+package codegen
+
+import (
+	"fmt"
+	"path/filepath"
+
+	"goa.design/goa/v3/codegen"
+	"goa.design/goa/v3/expr"
+)
+
+// sseClientFile returns the file implementing the SSE client code for SSE endpoints if any.
+// Relies on SSEData (ed.SSE) for all codegen needs.
+func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr) *codegen.File {
+	data := HTTPServices.Get(svc.Name())
+	if data == nil {
+		return nil
+	}
+	// Check if any endpoint has SSE
+	hasSSE := false
+	for _, ed := range data.Endpoints {
+		if ed.SSE != nil {
+			hasSSE = true
+			break
+		}
+	}
+	if !hasSSE {
+		return nil
+	}
+	path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "client", "sse.go")
+	sections := []*codegen.SectionTemplate{
+		codegen.Header(
+			"sse-client",
+			"client",
+			[]*codegen.ImportSpec{
+				{Path: "bytes"},
+				{Path: "context"},
+				{Path: "encoding/json"},
+				{Path: "io"},
+				{Path: "net/http"},
+				{Path: "fmt"},
+				{Path: "strings"},
+				{Path: "strconv"},
+				{Path: "sync"},
+				{Path: genpkg + "/" + codegen.SnakeCase(svc.Name())},
+				{Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views"},
+			},
+		),
+	}
+	sections = append(sections, sseClientTemplateSections(data)...) // add SSE client methods
+	return &codegen.File{Path: path, SectionTemplates: sections}
+}
+
+// sseClientTemplateSections returns section templates for SSE client endpoints.
+func sseClientTemplateSections(data *ServiceData) []*codegen.SectionTemplate {
+	sections := make([]*codegen.SectionTemplate, 0)
+	for _, ed := range data.Endpoints {
+		if ed.SSE == nil {
+			continue
+		}
+		// Create a map of template functions needed for the SSE template
+		funcs := map[string]interface{}{
+			"dict": func(values ...any) (map[string]any, error) {
+				if len(values)%2 != 0 {
+					return nil, fmt.Errorf("odd number of arguments")
+				}
+				dict := make(map[string]any, len(values)/2)
+				for i := 0; i < len(values); i += 2 {
+					key, ok := values[i].(string)
+					if !ok {
+						return nil, fmt.Errorf("dict keys must be strings")
+					}
+					dict[key] = values[i+1]
+				}
+				return dict, nil
+			},
+		}
+		sections = append(sections, &codegen.SectionTemplate{
+			Name:    "client-sse",
+			Source:  readTemplate("client_sse", "sse_parse"),
+			Data:    ed,
+			FuncMap: funcs,
+		})
+	}
+	return sections
+}
diff --git a/http/codegen/templates/cli_end.go.tpl b/http/codegen/templates/cli_end.go.tpl
index 1a006c4c..bb31b97d 100644
--- a/http/codegen/templates/cli_end.go.tpl
+++ b/http/codegen/templates/cli_end.go.tpl
@@ -5,7 +5,7 @@ return cli.ParseEndpoint(
 		goahttp.RequestEncoder,
 		goahttp.ResponseDecoder,
 		debug,
-{{- if needStream .Services }}
+{{- if needDialer .Services }}
 		dialer,
 	{{- range $svc := .Services }}
 		{{- if hasWebSocket $svc }}
diff --git a/http/codegen/templates/cli_streaming.go.tpl b/http/codegen/templates/cli_streaming.go.tpl
index a1cb16b4..076e22c2 100644
--- a/http/codegen/templates/cli_streaming.go.tpl
+++ b/http/codegen/templates/cli_streaming.go.tpl
@@ -1,4 +1,4 @@
-{{- if needStream .Services }}
+{{- if needDialer .Services }}
 	var (
     dialer *websocket.Dialer
   )
diff --git a/http/codegen/templates/client_sse.go.tpl b/http/codegen/templates/client_sse.go.tpl
new file mode 100644
index 00000000..e767c433
--- /dev/null
+++ b/http/codegen/templates/client_sse.go.tpl
@@ -0,0 +1,245 @@
+type (
+	// {{ .Method.VarName }}StreamImpl implements the {{ .ServiceName }}.{{ .Method.VarName }}ClientStream interface.
+	{{ .Method.VarName }}StreamImpl struct {
+		resp *http.Response
+		buffer []byte // Buffer for unprocessed data
+		lock sync.Mutex
+		closed bool
+	}
+)
+
+// {{ .Method.VarName }}StreamImpl implements the {{ .ServiceName }}.{{ .Method.VarName }}ClientStream interface.
+var _ {{ .ServiceName }}.{{ .Method.VarName }}ClientStream = (*{{ .Method.VarName }}StreamImpl)(nil)
+
+// New{{ .Method.VarName }}Stream creates a new {{ .ServiceName }}.{{ .Method.VarName }}ClientStream.
+func New{{ .Method.VarName }}Stream(resp *http.Response) {{ .ServiceName }}.{{ .Method.VarName }}ClientStream {
+	return &{{ .Method.VarName }}StreamImpl{
+		resp: resp,
+		buffer: make([]byte, 0, 4096), // Pre-allocate buffer
+	}
+}
+
+// Recv reads and returns the next event from the SSE stream.
+func (s *{{ .Method.VarName }}StreamImpl) Recv() (event {{ .SSE.EventTypeRef }}, err error) {
+	return s.RecvWithContext(context.Background())
+}
+
+// RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation.
+func (s *{{ .Method.VarName }}StreamImpl) RecvWithContext(ctx context.Context) (event {{ .SSE.EventTypeRef }}, err error) {
+	var byts []byte
+	byts, err = s.readEvent(ctx)
+	if err != nil {
+		if err == io.EOF || err == context.Canceled || err == context.DeadlineExceeded {
+			// Clean up on EOF or context cancellation
+			s.Close()
+			if err == io.EOF {
+				err = nil
+			}
+		}
+		return
+	}
+	return s.processEvent(byts)
+}
+
+// readEvent reads a single SSE event from the stream, respecting context
+// cancellation.  It first checks the internal buffer for a complete event
+// (delimited by double newlines). If no complete event is found, it reads from
+// the HTTP response body until it either finds an event boundary, reaches EOF,
+// or encounters an error. Any data after the event boundary is saved in the
+// buffer for the next call.
+func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte, error) {
+	const bufSize = 4096 // 4KB buffer size
+
+	// Check for event in existing buffer
+	event, ok := s.checkBuffer()
+	if ok {
+		return event, nil
+	}
+
+	// Initialize with any data from buffer
+	eventData := event
+	wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n'
+	buf := make([]byte, bufSize)
+
+	// Read data in chunks until we find an event or hit EOF
+	for {
+		// Check if context is done
+		select {
+		case <-ctx.Done():
+			if len(eventData) > 0 {
+				return eventData, nil
+			}
+			return nil, ctx.Err()
+		default:
+			// Continue processing
+		}
+
+		// Check if stream is closed
+		s.lock.Lock()
+		if s.closed {
+			s.lock.Unlock()
+			if len(eventData) > 0 {
+				return eventData, nil
+			}
+			return nil, io.EOF
+		}
+
+		// Read next chunk
+		n, err := s.resp.Body.Read(buf)
+		s.lock.Unlock()
+
+		// Handle read errors
+		if err != nil && err != io.EOF {
+			return nil, err
+		}
+
+		// Process data if we got any
+		if n > 0 {
+			// Look for event boundary in this chunk
+			for i := 0; i < n; i++ {
+				b := buf[i]
+				eventData = append(eventData, b)
+
+				// Check for double newlines (event boundary)
+				if b == '\n' && wasNewline {
+					// Save any remaining data for next read
+					if i+1 < n {
+						s.lock.Lock()
+						s.buffer = append(s.buffer[:0], buf[i+1:n]...)
+						s.lock.Unlock()
+					}
+					return eventData, nil
+				}
+
+				// Update newline tracking
+				wasNewline = (b == '\n')
+			}
+		}
+
+		// Return partial data at EOF
+		if err == io.EOF {
+			if len(eventData) > 0 {
+				return eventData, nil
+			}
+			return nil, io.EOF
+		}
+	}
+}
+
+// checkBuffer examines the internal buffer for a complete SSE event (delimited
+// by double newlines).  It returns two values: the event data (or all buffer
+// contents if no complete event is found), and a boolean indicating whether a
+// complete event was found. If a complete event is found, any remaining data
+// after the event is kept in the buffer for the next call.
+func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	// Quick return if buffer is empty
+	if len(s.buffer) == 0 {
+		return nil, false
+	}
+
+	// Look for double newline in buffer
+	for i := 0; i < len(s.buffer)-1; i++ {
+		if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' {
+			// Found complete event
+			eventEnd := i + 2 // Include both newlines
+			eventData := s.buffer[:eventEnd]
+
+			// Save remaining data for next time
+			if eventEnd < len(s.buffer) {
+				s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...)
+			} else {
+				s.buffer = s.buffer[:0]
+			}
+
+			return eventData, true
+		}
+	}
+
+	// No complete event found, return buffer contents
+	eventData := s.buffer
+	s.buffer = s.buffer[:0] // Clear buffer but keep capacity
+	return eventData, false
+}
+
+// Close closes the SSE stream and releases any associated resources.
+func (s *{{ .Method.VarName }}StreamImpl) Close() error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+	if s.closed {
+		return nil
+	}
+	s.closed = true
+	return s.resp.Body.Close()
+}
+
+// processEvent processes a raw SSE event into the expected type
+func (s *{{ .Method.VarName }}StreamImpl) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, err error) {
+	{{- if .SSE.EventIsStruct }}
+	event = new({{ .SSE.EventTypeName }})
+	{{- end }}
+	{{- if .SSE.IDField }}
+	var id string
+	{{- end }}
+	{{- if .SSE.EventField }}
+	var eventType string
+	{{- end }}
+	{{- if .SSE.RetryField }}
+	var retry int
+	{{- end }}
+	var dataLines []string
+	for _, line := range bytes.Split(eventData, []byte("\n")) {
+		if len(line) == 0 {
+			continue
+		}
+		if bytes.HasPrefix(line, []byte("data:")) {
+			dataLines = append(dataLines, s.trimHeader(len("data:"), line))
+			continue
+		}
+
+		{{- if .SSE.IDField }}
+		if bytes.HasPrefix(line, []byte("id:")) {
+			event.{{ .SSE.IDField }} = s.trimHeader(len("id:"), line)
+			continue
+		}
+		{{- end }}
+		
+		{{- if .SSE.EventField }}
+		if bytes.HasPrefix(line, []byte("event:")) {
+			event.{{ .SSE.EventField }} = s.trimHeader(len("event:"), line)
+			continue
+		}
+		{{- end }}
+		
+		{{- if .SSE.RetryField }}
+		if bytes.HasPrefix(line, []byte("retry:")) {
+			event.{{ .SSE.RetryField }} = s.trimHeader(len("retry:"), line)
+			continue
+		}
+		{{- end }}
+	}
+	if len(dataLines) > 0 {
+		dataContent := strings.Join(dataLines, "\n")
+		{{- if .SSE.DataField }}
+		{{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "TypeRef" .SSE.DataFieldTypeRef }}
+		{{- else }}
+		{{ template "partial_sse_parse" dict "Target" "event" "TypeRef" .SSE.EventTypeRef }}
+		{{- end }}
+	}
+
+	return
+}
+
+// trimHeader removes the header prefix and optional leading space
+func (s *{{ .Method.VarName }}StreamImpl) trimHeader(size int, data []byte) string {
+	if len(data) < size {
+		return string(data)
+	}
+	data = data[size:]
+	if len(data) > 0 && data[0] == ' ' {
+		data = data[1:]
+	}
+	return string(data)
+}
diff --git a/http/codegen/templates/endpoint_init.go.tpl b/http/codegen/templates/endpoint_init.go.tpl
index b0f117db..e2c5ad0a 100644
--- a/http/codegen/templates/endpoint_init.go.tpl
+++ b/http/codegen/templates/endpoint_init.go.tpl
@@ -8,7 +8,9 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder
 		encodeRequest  = {{ .RequestEncoder }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.InitName }}({{ .MultipartRequestEncoder.VarName }}){{ else }}c.encoder{{ end }})
 			{{- end }}
 		{{- end }}
+		{{- if not (isSSEEndpoint .) }}
 		decodeResponse = {{ .ResponseDecoder }}(c.decoder, c.RestoreResponseBody)
+		{{- end }}
 	)
 	return func(ctx context.Context, v any) (any, error) {
 		req, err := c.{{ .RequestInit.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }})
@@ -58,6 +60,25 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder
 			{{- end }}
 		{{- end }}
 		return stream, nil
+	{{- else if isSSEEndpoint . }}
+		// For SSE endpoints, connect and return a stream
+		resp, err := c.{{ .Method.VarName }}Doer.Do(req)
+		if err != nil {
+			return nil, goahttp.ErrRequestError("{{ .ServiceName }}", "{{ .Method.Name }}", err)
+		}
+		
+		if resp.StatusCode != http.StatusOK {
+			resp.Body.Close()
+			return nil, fmt.Errorf("unexpected status from SSE endpoint: %d", resp.StatusCode)
+		}
+		
+		contentType := resp.Header.Get("Content-Type")
+		if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") {
+			resp.Body.Close()
+			return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType)
+		}
+		
+		return New{{ .Method.VarName }}Stream(resp), nil
 	{{- else }}
 		resp, err := c.{{ .Method.VarName }}Doer.Do(req)
 		if err != nil {
diff --git a/http/codegen/templates/parse_endpoint.go.tpl b/http/codegen/templates/parse_endpoint.go.tpl
index 55ef1735..16f1ab16 100644
--- a/http/codegen/templates/parse_endpoint.go.tpl
+++ b/http/codegen/templates/parse_endpoint.go.tpl
@@ -9,7 +9,7 @@ func ParseEndpoint(
 	{{- if streamingCmdExists .Commands }}
 	dialer goahttp.Dialer,
 		{{- range .Commands }}
-			{{- if .NeedStream }}
+			{{- if .NeedDialer }}
 				{{ .VarName }}Configurer *{{ .PkgName }}.ConnConfigurer,
 			{{- end }}
 		{{- end }}
@@ -35,7 +35,7 @@ func ParseEndpoint(
 		switch svcn {
 	{{- range .Commands }}
 		case "{{ .Name }}":
-			c := {{ .PkgName }}.NewClient(scheme, host, doer, enc, dec, restore{{ if .NeedStream }}, dialer, {{ .VarName }}Configurer{{ end }})
+			c := {{ .PkgName }}.NewClient(scheme, host, doer, enc, dec, restore{{ if .NeedDialer }}, dialer, {{ .VarName }}Configurer{{ end }})
 			switch epn {
 		{{- $pkgName := .PkgName }}
 		{{- range .Subcommands }}
diff --git a/http/codegen/templates/partial/sse_format.go.tpl b/http/codegen/templates/partial/sse_format.go.tpl
new file mode 100644
index 00000000..1745c219
--- /dev/null
+++ b/http/codegen/templates/partial/sse_format.go.tpl
@@ -0,0 +1,21 @@
+{{- if eq .TypeRef "string" }}
+	data = {{ .VarName }}
+{{- else if eq .TypeRef "boolean" }}
+	if {{ .VarName }} {
+		data = "true"
+	} else {
+		data = "false"
+	}
+{{- else if eq .TypeRef "bytes" }}
+	data = string({{ .VarName }})
+{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") (eq .TypeRef "int64") (eq .TypeRef "uint") (eq .TypeRef "uint32") (eq .TypeRef "uint64") }}
+	data = fmt.Sprintf("%d", {{ .VarName }})
+{{- else if or (eq .TypeRef "float32") (eq .TypeRef "float64") }}
+	data = fmt.Sprintf("%g", {{ .VarName }})
+{{- else }}
+	byts, err := json.Marshal({{ .VarName }})
+	if err != nil {
+		return err
+	}
+	data = string(byts)
+{{- end }}
\ No newline at end of file
diff --git a/http/codegen/templates/partial/sse_parse.go.tpl b/http/codegen/templates/partial/sse_parse.go.tpl
new file mode 100644
index 00000000..b9eb5245
--- /dev/null
+++ b/http/codegen/templates/partial/sse_parse.go.tpl
@@ -0,0 +1,53 @@
+{{- if eq .TypeRef "string" }}
+	{{ .Target }} = dataContent
+{{- else if eq .TypeRef "boolean" }}
+	var val bool
+	val, err = strconv.ParseBool(dataContent)
+	if err != nil {
+		return
+	}
+	{{ .Target }} = val
+{{- else if eq .TypeRef "bytes" }}
+	{{ .Target }} = []byte(dataContent)
+{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") }}
+	var val int64
+	val, err = strconv.ParseInt(dataContent, 10, 0)
+	if err != nil {
+		return
+	}
+	{{ .Target }} = {{ .TypeRef }}(val)
+{{- else if eq .TypeRef "int64" }}
+	{{ .Target }}, err = strconv.ParseInt(dataContent, 10, 64)
+	if err != nil {
+		return
+	}
+{{- else if or (eq .TypeRef "uint") (eq .TypeRef "uint32") }}
+	var val uint64
+	val, err = strconv.ParseUint(dataContent, 10, 0)
+	if err != nil {
+		return
+	}
+	{{ .Target }} = {{ .TypeRef }}(val)
+{{- else if eq .TypeRef "uint64" }}
+	{{ .Target }}, err = strconv.ParseUint(dataContent, 10, 64)
+	if err != nil {
+		return
+	}
+{{- else if eq .TypeRef "float32" }}
+	var val float64
+	val, err = strconv.ParseFloat(dataContent, 32)
+	if err != nil {
+		return
+	}
+	{{ .Target }} = float32(val)
+{{- else if eq .TypeRef "float64" }}
+	{{ .Target }}, err = strconv.ParseFloat(dataContent, 64)
+	if err != nil {
+		return
+	}
+{{- else }}
+	err = json.Unmarshal([]byte(dataContent), &{{ .Target }})
+	if err != nil {
+		return
+	}
+{{- end }}
\ No newline at end of file
diff --git a/http/codegen/templates/request_init.go.tpl b/http/codegen/templates/request_init.go.tpl
index 09e19138..c6306197 100644
--- a/http/codegen/templates/request_init.go.tpl
+++ b/http/codegen/templates/request_init.go.tpl
@@ -45,7 +45,7 @@
 		}
 		body = rd.Body
 {{- end }}
-	{{- if .IsStreaming }}
+	{{- if .IsWebSocket }}
 		scheme := c.scheme
 		switch c.scheme {
 		case "http":
@@ -54,7 +54,7 @@
 			scheme = "wss"
 		}
 	{{- end }}
-	u := &url.URL{Scheme: {{ if .IsStreaming }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})}
+	u := &url.URL{Scheme: {{ if .IsWebSocket }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})}
 	req, err := http.NewRequest("{{ .Verb }}", u.String(), {{ if .RequestStruct }}body{{ else }}nil{{ end }})
 	if err != nil {
 		return nil, goahttp.ErrInvalidURL("{{ .ServiceName }}", "{{ .EndpointName }}", u.String(), err)
diff --git a/http/codegen/templates/server_configure.go.tpl b/http/codegen/templates/server_configure.go.tpl
index 1f186a9a..c337e9e6 100644
--- a/http/codegen/templates/server_configure.go.tpl
+++ b/http/codegen/templates/server_configure.go.tpl
@@ -10,7 +10,7 @@
 	)
 	{
 		eh := errorHandler(ctx)
-	{{- if needStream .Services }}
+	{{- if needDialer .Services }}
 		upgrader := &websocket.Upgrader{}
 	{{- end }}
 	{{- range $svc := .Services }}
diff --git a/http/codegen/templates/server_handler_init.go.tpl b/http/codegen/templates/server_handler_init.go.tpl
index fdd3a452..cd2b4512 100644
--- a/http/codegen/templates/server_handler_init.go.tpl
+++ b/http/codegen/templates/server_handler_init.go.tpl
@@ -11,19 +11,19 @@ func {{ .HandlerInit }}(
 	configurer goahttp.ConnConfigureFunc,
 	{{- end }}
 ) http.Handler {
-	{{- if (or (mustDecodeRequest .) (not (or .Redirect (isWebSocketEndpoint .))) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }}
+	{{- if (or (mustDecodeRequest .) (not (or .Redirect (isWebSocketEndpoint .) (isSSEEndpoint .))) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }}
 	var (
 	{{- end }}
 		{{- if mustDecodeRequest . }}
 		decodeRequest  = {{ .RequestDecoder }}(mux, decoder)
 		{{- end }}
-		{{- if not (or .Redirect (isWebSocketEndpoint .)) }}
+		{{- if not (or .Redirect (isWebSocketEndpoint .) (isSSEEndpoint .)) }}
 		encodeResponse = {{ .ResponseEncoder }}(encoder)
 		{{- end }}
 		{{- if (or (mustDecodeRequest .) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }}
 		encodeError    = {{ if .Errors }}{{ .ErrorEncoder }}{{ else }}goahttp.ErrorEncoder{{ end }}(encoder, formatter)
 		{{- end }}
-	{{- if (or (mustDecodeRequest .) (not (or .Redirect (isWebSocketEndpoint .))) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }}
+	{{- if (or (mustDecodeRequest .) (not (or .Redirect (isWebSocketEndpoint .) (isSSEEndpoint .))) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }}
 	)
 	{{- end }}
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -58,6 +58,30 @@ func {{ .HandlerInit }}(
 		{{- end }}
 		}
 		_, err = endpoint(ctx, v)
+	{{- else if isSSEEndpoint . }}
+		{{- if .SSE.RequestIDField }}
+		// Set Last-Event-ID header if present
+		if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" {
+			ctx = context.WithValue(ctx, "last-event-id", lastEventID)
+			{{- if .Payload.Ref }}
+			{{- if eq .Method.Payload.Type.Name "Object" }}
+			p := payload.({{ .Payload.Ref }})
+			p.{{ .SSE.RequestIDField }} = lastEventID
+			payload = p
+			{{- end }}
+			{{- end }}
+		}
+		{{- end }}
+		v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{
+			Stream: &{{ .SSE.StructName }}{
+				w: w,
+				r: r,
+			},
+		{{- if .Payload.Ref }}
+			Payload: payload.({{ .Payload.Ref }}),
+		{{- end }}
+		}
+		_, err = endpoint(ctx, v)
 	{{- else if .Method.SkipRequestBodyEncodeDecode }}
 		data := &{{ .ServicePkgName }}.{{ .Method.RequestStruct }}{ {{ if .Payload.Ref }}Payload: payload.({{ .Payload.Ref }}), {{ end }}Body: r.Body }
 		res, err := endpoint(ctx, data)
@@ -115,7 +139,7 @@ func {{ .HandlerInit }}(
 			return
 		}
 	{{- end }}
-	{{- if not (or .Redirect (isWebSocketEndpoint .)) }}
+	{{- if not (or .Redirect (isWebSocketEndpoint .) (isSSEEndpoint .)) }}
 		if err := encodeResponse(ctx, w, {{ if and .Method.SkipResponseBodyEncodeDecode .Result.Ref }}o.Result{{ else }}res{{ end }}); err != nil {
 			errhandler(ctx, w, err)
 			{{- if .Method.SkipResponseBodyEncodeDecode }}
diff --git a/http/codegen/templates/server_sse.go.tpl b/http/codegen/templates/server_sse.go.tpl
new file mode 100644
index 00000000..2135c235
--- /dev/null
+++ b/http/codegen/templates/server_sse.go.tpl
@@ -0,0 +1,78 @@
+{{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructName .SSE.Interface | comment }}
+type {{ .SSE.StructName }} struct {
+	{{ comment "once ensures the headers are written once." }}
+	once sync.Once
+	{{ comment "w is the HTTP response writer used to send the SSE events." }}
+	w http.ResponseWriter
+	{{ comment "r is the HTTP request." }}
+	r *http.Request
+}
+
+{{ printf "%s %s" .SSE.SendName .SSE.SendDesc | comment }}
+func (s *{{ .SSE.StructName }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error {
+	return s.{{ .SSE.SendWithContextName }}(context.Background(), v)
+}
+
+{{ printf "%s %s" .SSE.SendWithContextName .SSE.SendWithContextDesc | comment }}
+func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+
+	{{- if .Method.ViewedResult }}
+		{{- if .Method.ViewedResult.ViewName }}
+	res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, {{ printf "%q" .Method.ViewedResult.ViewName }})
+		{{- else }}
+	res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, "default")
+		{{- end }}
+	{{- else }}
+	res := v
+	{{- end }}
+	
+	{{ if .SSE.IDField }}
+	if id := res.{{ .SSE.IDField }}; id != "" {
+		fmt.Fprintf(s.w, "id: %s\n", id)
+	}
+	{{- end }}
+
+	{{- if .SSE.EventField }}
+	if event := res.{{ .SSE.EventField }}; event != "" {
+		fmt.Fprintf(s.w, "event: %s\n", event)
+	}
+	{{- end }}
+
+	{{- if .SSE.RetryField }}
+	if retry := res.{{ .SSE.RetryField }}; retry > 0 {
+		fmt.Fprintf(s.w, "retry: %d\n", retry)
+	}
+	{{- end }}
+
+	var data string
+	{{- if .SSE.DataField }}
+		dataField := res.{{ .SSE.DataField }}
+		{{- template "partial_sse_format" dict "TypeRef" .SSE.DataFieldTypeRef "VarName" "dataField" }}
+	{{- else }}
+		{{- template "partial_sse_format" dict "TypeRef" .SSE.EventTypeRef "VarName" "res" }}
+	{{- end }}
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+{{ comment "Close is a no-op for SSE. We keep the method for compatibility with other stream types." }}
+func (s *{{ .SSE.StructName }}) Close() error {
+	return nil
+}
diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go
index b0df1760..d4304cc3 100644
--- a/http/codegen/websocket.go
+++ b/http/codegen/websocket.go
@@ -3,6 +3,7 @@ package codegen
 import (
 	"fmt"
 	"path/filepath"
+	"slices"
 	"strings"
 
 	"goa.design/goa/v3/codegen"
@@ -74,6 +75,9 @@ type (
 
 // initWebSocketData initializes the WebSocket related data in ed.
 func initWebSocketData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) {
+	if e.SSE != nil {
+		return
+	}
 	var (
 		svrRecvTypeName        string
 		svrRecvTypeRef         string
@@ -451,12 +455,7 @@ func clientWSSections(data *ServiceData) []*codegen.SectionTemplate {
 // hasWebSocket returns true if at least one of the endpoints in the service
 // defines a streaming payload or result.
 func hasWebSocket(sd *ServiceData) bool {
-	for _, e := range sd.Endpoints {
-		if isWebSocketEndpoint(e) {
-			return true
-		}
-	}
-	return false
+	return slices.ContainsFunc(sd.Endpoints, isWebSocketEndpoint)
 }
 
 // isWebSocketEndpoint returns true if the endpoint defines a streaming payload


Test patch:
diff --git a/eval/eval_test.go b/eval/eval_test.go
index d334fec1..25ffa2d8 100644
--- a/eval/eval_test.go
+++ b/eval/eval_test.go
@@ -1,11 +1,14 @@
 package eval_test
 
 import (
+	"errors"
 	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	. "goa.design/goa/v3/dsl"
+	"goa.design/goa/v3/eval"
 	"goa.design/goa/v3/expr"
 )
 
@@ -114,3 +117,118 @@ func TestTooManyArgError(t *testing.T) {
 		})
 	}
 }
+
+// mockExpr is a test implementation of the Expression interface
+type mockExpr struct{}
+
+func (m *mockExpr) EvalName() string {
+	return "MockExpression"
+}
+
+// TestValidationErrors tests the ValidationErrors type methods
+func TestValidationErrors(t *testing.T) {
+
+	t.Run("Error", func(t *testing.T) {
+		// Test Error() method
+		expr1 := &mockExpr{}
+		expr2 := &mockExpr{}
+
+		verr := &eval.ValidationErrors{
+			Errors:      []error{errors.New("error1"), errors.New("error2")},
+			Expressions: []eval.Expression{expr1, expr2},
+		}
+
+		errStr := verr.Error()
+		assert.Contains(t, errStr, "MockExpression: error1")
+		assert.Contains(t, errStr, "MockExpression: error2")
+		assert.Contains(t, errStr, "\n") // Should contain a newline between errors
+	})
+
+	t.Run("Merge", func(t *testing.T) {
+		// Test Merge() method
+		expr1 := &mockExpr{}
+		expr2 := &mockExpr{}
+		expr3 := &mockExpr{}
+
+		verr1 := &eval.ValidationErrors{
+			Errors:      []error{errors.New("error1")},
+			Expressions: []eval.Expression{expr1},
+		}
+
+		verr2 := &eval.ValidationErrors{
+			Errors:      []error{errors.New("error2"), errors.New("error3")},
+			Expressions: []eval.Expression{expr2, expr3},
+		}
+
+		// Test merging with nil
+		verrCopy := *verr1
+		verrCopy.Merge(nil)
+		assert.Equal(t, 1, len(verrCopy.Errors))
+		assert.Equal(t, 1, len(verrCopy.Expressions))
+
+		// Test merging with empty but non-nil ValidationErrors
+		verrCopy2 := *verr1
+		emptyVerr := &eval.ValidationErrors{}
+		verrCopy2.Merge(emptyVerr)
+		assert.Equal(t, 1, len(verrCopy2.Errors), "Merging with empty ValidationErrors should not change the error count")
+		assert.Equal(t, 1, len(verrCopy2.Expressions), "Merging with empty ValidationErrors should not change the expression count")
+
+		// Test normal merge
+		verr1.Merge(verr2)
+		assert.Equal(t, 3, len(verr1.Errors))
+		assert.Equal(t, 3, len(verr1.Expressions))
+		assert.Equal(t, expr1, verr1.Expressions[0])
+		assert.Equal(t, expr2, verr1.Expressions[1])
+		assert.Equal(t, expr3, verr1.Expressions[2])
+	})
+
+	t.Run("Add", func(t *testing.T) {
+		// Test Add() method
+		expr1 := &mockExpr{}
+
+		verr := &eval.ValidationErrors{}
+		verr.Add(expr1, "test error %s", "message")
+
+		require.Equal(t, 1, len(verr.Errors))
+		require.Equal(t, 1, len(verr.Expressions))
+		assert.Equal(t, "test error message", verr.Errors[0].Error())
+		assert.Equal(t, expr1, verr.Expressions[0])
+	})
+
+	t.Run("AddError_Simple", func(t *testing.T) {
+		// Test AddError() with a simple error
+		expr1 := &mockExpr{}
+		simpleErr := errors.New("simple error")
+
+		verr := &eval.ValidationErrors{}
+		verr.AddError(expr1, simpleErr)
+
+		require.Equal(t, 1, len(verr.Errors))
+		require.Equal(t, 1, len(verr.Expressions))
+		assert.Equal(t, simpleErr, verr.Errors[0])
+		assert.Equal(t, expr1, verr.Expressions[0])
+	})
+
+	t.Run("AddError_ValidationErrors", func(t *testing.T) {
+		// Test AddError() with another ValidationErrors
+		expr1 := &mockExpr{}
+		expr2 := &mockExpr{}
+		expr3 := &mockExpr{}
+
+		nestedVerr := &eval.ValidationErrors{
+			Errors:      []error{errors.New("nested error 1"), errors.New("nested error 2")},
+			Expressions: []eval.Expression{expr2, expr3},
+		}
+
+		verr := &eval.ValidationErrors{}
+		verr.AddError(expr1, nestedVerr) // expr1 should be ignored since we're adding a ValidationErrors
+
+		// The nested ValidationErrors should be flattened
+		require.Equal(t, 2, len(verr.Errors))
+		require.Equal(t, 2, len(verr.Expressions))
+		assert.Equal(t, "nested error 1", verr.Errors[0].Error())
+		assert.Equal(t, "nested error 2", verr.Errors[1].Error())
+		assert.Equal(t, expr2, verr.Expressions[0])
+		assert.Equal(t, expr3, verr.Expressions[1])
+	})
+}
diff --git a/expr/http_sse_test.go b/expr/http_sse_test.go
new file mode 100644
index 00000000..da731f2f
--- /dev/null
+++ b/expr/http_sse_test.go
@@ -0,0 +1,175 @@
+package expr_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"goa.design/goa/v3/expr"
+)
+
+func TestHTTPSSEExprValidation(t *testing.T) {
+	cases := map[string]struct {
+		SSE          *expr.HTTPSSEExpr
+		Payload      *expr.AttributeExpr
+		Result       *expr.AttributeExpr
+		ExpectedErrs []string
+	}{
+		"valid-empty": {
+			SSE:     &expr.HTTPSSEExpr{},
+			Payload: &expr.AttributeExpr{Type: &expr.Object{}},
+			Result:  &expr.AttributeExpr{Type: &expr.Object{}},
+		},
+		"valid-with-fields": {
+			SSE: &expr.HTTPSSEExpr{
+				RequestIDField: "request_id",
+				DataField:      "data",
+				IDField:        "id",
+				EventField:     "event",
+				RetryField:     "retry",
+			},
+			Payload: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "request_id", Attribute: &expr.AttributeExpr{Type: expr.String}},
+				},
+			},
+			Result: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "data", Attribute: &expr.AttributeExpr{Type: expr.String}},
+					&expr.NamedAttributeExpr{Name: "id", Attribute: &expr.AttributeExpr{Type: expr.String}},
+					&expr.NamedAttributeExpr{Name: "event", Attribute: &expr.AttributeExpr{Type: expr.String}},
+					&expr.NamedAttributeExpr{Name: "retry", Attribute: &expr.AttributeExpr{Type: expr.Int}},
+				},
+			},
+		},
+		"invalid-id-field-type": {
+			SSE: &expr.HTTPSSEExpr{
+				IDField: "id",
+			},
+			Payload: &expr.AttributeExpr{Type: &expr.Object{}},
+			Result: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "id", Attribute: &expr.AttributeExpr{Type: expr.Int}},
+				},
+			},
+			ExpectedErrs: []string{"cannot use \"id\" for SSE event id field: attribute type must be one of string"},
+		},
+		"invalid-request-id-field-type": {
+			SSE: &expr.HTTPSSEExpr{
+				RequestIDField: "request_id",
+			},
+			Payload: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "request_id", Attribute: &expr.AttributeExpr{Type: expr.Int}},
+				},
+			},
+			Result:       &expr.AttributeExpr{Type: &expr.Object{}},
+			ExpectedErrs: []string{"cannot use \"request_id\" for SSE request ID field: attribute type must be one of string"},
+		},
+		"invalid-event-field-type": {
+			SSE: &expr.HTTPSSEExpr{
+				EventField: "event",
+			},
+			Payload: &expr.AttributeExpr{Type: &expr.Object{}},
+			Result: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "event", Attribute: &expr.AttributeExpr{Type: expr.Int}},
+				},
+			},
+			ExpectedErrs: []string{"cannot use \"event\" for SSE event type field: attribute type must be one of string"},
+		},
+		"invalid-retry-field-type": {
+			SSE: &expr.HTTPSSEExpr{
+				RetryField: "retry",
+			},
+			Payload: &expr.AttributeExpr{Type: &expr.Object{}},
+			Result: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "retry", Attribute: &expr.AttributeExpr{Type: expr.Boolean}},
+				},
+			},
+			ExpectedErrs: []string{"cannot use \"retry\" for SSE event retry field: attribute type must be one of int, int32, int64, uint, uint32, uint64"},
+		},
+		"missing-field": {
+			SSE: &expr.HTTPSSEExpr{
+				DataField: "missing",
+			},
+			Payload: &expr.AttributeExpr{Type: &expr.Object{}},
+			Result: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "data", Attribute: &expr.AttributeExpr{Type: expr.String}},
+				},
+			},
+			ExpectedErrs: []string{"cannot use \"missing\" for SSE event data field: attribute not found in result type"},
+		},
+		"missing-request-id-field": {
+			SSE: &expr.HTTPSSEExpr{
+				RequestIDField: "missing",
+			},
+			Payload: &expr.AttributeExpr{
+				Type: &expr.Object{
+					&expr.NamedAttributeExpr{Name: "request_id", Attribute: &expr.AttributeExpr{Type: expr.String}},
+				},
+			},
+			Result:       &expr.AttributeExpr{Type: &expr.Object{}},
+			ExpectedErrs: []string{"cannot use \"missing\" for SSE request ID field: attribute not found in result type"},
+		},
+		"nil-result-type": {
+			SSE: &expr.HTTPSSEExpr{
+				DataField: "data",
+			},
+			Payload: &expr.AttributeExpr{Type: &expr.Object{}},
+		},
+		"nil-payload-type": {
+			SSE: &expr.HTTPSSEExpr{
+				RequestIDField: "request_id",
+			},
+		},
+		"empty-result-type": {
+			SSE: &expr.HTTPSSEExpr{
+				DataField: "data",
+			},
+			Payload:      &expr.AttributeExpr{Type: &expr.Object{}},
+			Result:       &expr.AttributeExpr{Type: expr.Int},
+			ExpectedErrs: []string{"cannot use \"data\" for SSE event data field: result type is not an object"},
+		},
+		"non-object-result-type": {
+			SSE: &expr.HTTPSSEExpr{
+				DataField: "data",
+			},
+			Payload:      &expr.AttributeExpr{Type: &expr.Object{}},
+			Result:       &expr.AttributeExpr{Type: expr.String},
+			ExpectedErrs: []string{"cannot use \"data\" for SSE event data field: result type is not an object"},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			// Create a mock HTTP endpoint expression for validation
+			methodExpr := &expr.MethodExpr{
+				Name:    "TestMethod",
+				Payload: tc.Payload,
+				Result:  tc.Result,
+				Stream:  expr.ServerStreamKind, // Must be a streaming method for SSE
+			}
+			endpoint := &expr.HTTPEndpointExpr{
+				MethodExpr: methodExpr,
+				SSE:        tc.SSE,
+			}
+
+			// Run validation
+			err := tc.SSE.Validate(endpoint)
+
+			// Check results
+			if len(tc.ExpectedErrs) == 0 {
+				require.NoError(t, err, "expected no error")
+			} else {
+				require.Error(t, err, "expected error, got none")
+				for _, expected := range tc.ExpectedErrs {
+					assert.Contains(t, err.Error(), expected, "error should contain expected message")
+				}
+			}
+		})
+	}
+}
diff --git a/http/codegen/example_cli_test.go b/http/codegen/example_cli_test.go
index c6de2bae..6b12fea1 100644
--- a/http/codegen/example_cli_test.go
+++ b/http/codegen/example_cli_test.go
@@ -38,7 +38,7 @@ func TestExampleCLIFiles(t *testing.T) {
 				require.NoError(t, s.Write(&buf))
 			}
 			code := codegen.FormatTestCode(t, "package foo\n"+buf.String())
-			golden := filepath.Join("testdata", "client-"+c.Name+".golden")
+			golden := filepath.Join("testdata", "golden", "client-"+c.Name+".golden")
 			compareOrUpdateGolden(t, code, golden)
 		})
 	}
diff --git a/http/codegen/example_server_test.go b/http/codegen/example_server_test.go
index ffd6e1e7..e0f61014 100644
--- a/http/codegen/example_server_test.go
+++ b/http/codegen/example_server_test.go
@@ -106,7 +106,7 @@ func TestExampleServerFiles(t *testing.T) {
 					require.NoError(t, s.Write(&buf))
 				}
 				code := codegen.FormatTestCode(t, "package foo\n"+buf.String())
-				golden := filepath.Join("testdata", "server-"+c.Name+".golden")
+				golden := filepath.Join("testdata", "golden", "server-"+c.Name+".golden")
 				compareOrUpdateGolden(t, code, golden)
 			})
 		}
diff --git a/http/codegen/sse_server_test.go b/http/codegen/sse_server_test.go
new file mode 100644
index 00000000..118a3b07
--- /dev/null
+++ b/http/codegen/sse_server_test.go
@@ -0,0 +1,41 @@
+package codegen
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"goa.design/goa/v3/codegen"
+	"goa.design/goa/v3/expr"
+	"goa.design/goa/v3/http/codegen/testdata"
+)
+
+func TestSSE(t *testing.T) {
+	cases := []struct {
+		Name string
+		DSL  func()
+	}{
+		{"string", testdata.SSEStringDSL},
+		{"int", testdata.SSEIntDSL},
+		{"bool", testdata.SSEBoolDSL},
+		{"object", testdata.SSEObjectDSL},
+		{"data-field", testdata.SSEDataFieldDSL},
+		{"data-id-field", testdata.SSEDataIDFieldDSL},
+		{"request-id", testdata.SSERequestIDDSL},
+		{"all-fields", testdata.SSEAllFieldsDSL},
+	}
+
+	for _, c := range cases {
+		t.Run(c.Name, func(t *testing.T) {
+			RunHTTPDSL(t, c.DSL)
+			fs := ServerFiles("", expr.Root)
+			require.Len(t, fs, 3)
+			sections := fs[1].SectionTemplates
+			require.Greater(t, len(sections), 1)
+			code := codegen.SectionCode(t, sections[1])
+			golden := filepath.Join("testdata", "golden", "sse-"+c.Name+".golden")
+			compareOrUpdateGolden(t, code, golden)
+		})
+	}
+}
diff --git a/http/codegen/testdata/client-no-server.golden b/http/codegen/testdata/golden/client-no-server.golden
similarity index 100%
rename from http/codegen/testdata/client-no-server.golden
rename to http/codegen/testdata/golden/client-no-server.golden
diff --git a/http/codegen/testdata/client-server-hosting-multiple-services.golden b/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden
similarity index 100%
rename from http/codegen/testdata/client-server-hosting-multiple-services.golden
rename to http/codegen/testdata/golden/client-server-hosting-multiple-services.golden
diff --git a/http/codegen/testdata/client-server-hosting-service-subset.golden b/http/codegen/testdata/golden/client-server-hosting-service-subset.golden
similarity index 100%
rename from http/codegen/testdata/client-server-hosting-service-subset.golden
rename to http/codegen/testdata/golden/client-server-hosting-service-subset.golden
diff --git a/http/codegen/testdata/client-streaming-multiple-services.golden b/http/codegen/testdata/golden/client-streaming-multiple-services.golden
similarity index 100%
rename from http/codegen/testdata/client-streaming-multiple-services.golden
rename to http/codegen/testdata/golden/client-streaming-multiple-services.golden
diff --git a/http/codegen/testdata/client-streaming.golden b/http/codegen/testdata/golden/client-streaming.golden
similarity index 100%
rename from http/codegen/testdata/client-streaming.golden
rename to http/codegen/testdata/golden/client-streaming.golden
diff --git a/http/codegen/testdata/server-no-server.golden b/http/codegen/testdata/golden/server-no-server.golden
similarity index 100%
rename from http/codegen/testdata/server-no-server.golden
rename to http/codegen/testdata/golden/server-no-server.golden
diff --git a/http/codegen/testdata/server-server-hosting-multiple-services.golden b/http/codegen/testdata/golden/server-server-hosting-multiple-services.golden
similarity index 100%
rename from http/codegen/testdata/server-server-hosting-multiple-services.golden
rename to http/codegen/testdata/golden/server-server-hosting-multiple-services.golden
diff --git a/http/codegen/testdata/server-server-hosting-service-subset.golden b/http/codegen/testdata/golden/server-server-hosting-service-subset.golden
similarity index 100%
rename from http/codegen/testdata/server-server-hosting-service-subset.golden
rename to http/codegen/testdata/golden/server-server-hosting-service-subset.golden
diff --git a/http/codegen/testdata/server-server-hosting-service-with-file-server.golden b/http/codegen/testdata/golden/server-server-hosting-service-with-file-server.golden
similarity index 100%
rename from http/codegen/testdata/server-server-hosting-service-with-file-server.golden
rename to http/codegen/testdata/golden/server-server-hosting-service-with-file-server.golden
diff --git a/http/codegen/testdata/server-streaming.golden b/http/codegen/testdata/golden/server-streaming.golden
similarity index 100%
rename from http/codegen/testdata/server-streaming.golden
rename to http/codegen/testdata/golden/server-streaming.golden
diff --git a/http/codegen/testdata/golden/sse-all-fields.golden b/http/codegen/testdata/golden/sse-all-fields.golden
new file mode 100644
index 00000000..6d0df711
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-all-fields.golden
@@ -0,0 +1,68 @@
+// SSEAllFieldsMethodServerStream implements the
+// sseallfieldsservice.SSEAllFieldsMethodServerStream interface using
+// Server-Sent Events.
+type SSEAllFieldsMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of
+// "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod"
+// endpoint SSE connection.
+func (s *SSEAllFieldsMethodServerStream) Send(v *sseallfieldsservice.SSEAllFieldsMethodResult) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of
+// "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod"
+// endpoint SSE connection with context.
+func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v *sseallfieldsservice.SSEAllFieldsMethodResult) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	if id := res.id; id != "" {
+		fmt.Fprintf(s.w, "id: %s\n", id)
+	}
+	if event := res.event; event != "" {
+		fmt.Fprintf(s.w, "event: %s\n", event)
+	}
+	if retry := res.retry; retry > 0 {
+		fmt.Fprintf(s.w, "retry: %d\n", retry)
+	}
+
+	var data string
+	dataField := res.data
+	byts, err := json.Marshal(dataField)
+	if err != nil {
+		return err
+	}
+	data = string(byts)
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSEAllFieldsMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/golden/sse-bool.golden b/http/codegen/testdata/golden/sse-bool.golden
new file mode 100644
index 00000000..b58d798c
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-bool.golden
@@ -0,0 +1,54 @@
+// SSEBoolMethodServerStream implements the
+// sseboolservice.SSEBoolMethodServerStream interface using Server-Sent Events.
+type SSEBoolMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of "bool" to the "SSEBoolMethod" endpoint SSE
+// connection.
+func (s *SSEBoolMethodServerStream) Send(v bool) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of "bool" to the
+// "SSEBoolMethod" endpoint SSE connection with context.
+func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	var data string
+	byts, err := json.Marshal(res)
+	if err != nil {
+		return err
+	}
+	data = string(byts)
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSEBoolMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/golden/sse-data-field.golden b/http/codegen/testdata/golden/sse-data-field.golden
new file mode 100644
index 00000000..a09ace1c
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-data-field.golden
@@ -0,0 +1,58 @@
+// SSEDataFieldMethodServerStream implements the
+// ssedatafieldservice.SSEDataFieldMethodServerStream interface using
+// Server-Sent Events.
+type SSEDataFieldMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of
+// "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod"
+// endpoint SSE connection.
+func (s *SSEDataFieldMethodServerStream) Send(v *ssedatafieldservice.SSEDataFieldMethodResult) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of
+// "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod"
+// endpoint SSE connection with context.
+func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedatafieldservice.SSEDataFieldMethodResult) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	var data string
+	dataField := res.data
+	byts, err := json.Marshal(dataField)
+	if err != nil {
+		return err
+	}
+	data = string(byts)
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSEDataFieldMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/golden/sse-data-id-field.golden b/http/codegen/testdata/golden/sse-data-id-field.golden
new file mode 100644
index 00000000..a1ee61b4
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-data-id-field.golden
@@ -0,0 +1,62 @@
+// SSEDataIDFieldMethodServerStream implements the
+// ssedataidfieldservice.SSEDataIDFieldMethodServerStream interface using
+// Server-Sent Events.
+type SSEDataIDFieldMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of
+// "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the
+// "SSEDataIDFieldMethod" endpoint SSE connection.
+func (s *SSEDataIDFieldMethodServerStream) Send(v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of
+// "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the
+// "SSEDataIDFieldMethod" endpoint SSE connection with context.
+func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	if id := res.id; id != "" {
+		fmt.Fprintf(s.w, "id: %s\n", id)
+	}
+
+	var data string
+	dataField := res.data
+	byts, err := json.Marshal(dataField)
+	if err != nil {
+		return err
+	}
+	data = string(byts)
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSEDataIDFieldMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/golden/sse-int.golden b/http/codegen/testdata/golden/sse-int.golden
new file mode 100644
index 00000000..d789c179
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-int.golden
@@ -0,0 +1,50 @@
+// SSEIntMethodServerStream implements the
+// sseintservice.SSEIntMethodServerStream interface using Server-Sent Events.
+type SSEIntMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of "int" to the "SSEIntMethod" endpoint SSE
+// connection.
+func (s *SSEIntMethodServerStream) Send(v int) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of "int" to the
+// "SSEIntMethod" endpoint SSE connection with context.
+func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	var data string
+	data = fmt.Sprintf("%d", res)
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSEIntMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/golden/sse-object.golden b/http/codegen/testdata/golden/sse-object.golden
new file mode 100644
index 00000000..a5f03b2c
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-object.golden
@@ -0,0 +1,56 @@
+// SSEObjectMethodServerStream implements the
+// sseobjectservice.SSEObjectMethodServerStream interface using Server-Sent
+// Events.
+type SSEObjectMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of "sseobjectservice.SSEObjectMethodResult" to
+// the "SSEObjectMethod" endpoint SSE connection.
+func (s *SSEObjectMethodServerStream) Send(v *sseobjectservice.SSEObjectMethodResult) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of
+// "sseobjectservice.SSEObjectMethodResult" to the "SSEObjectMethod" endpoint
+// SSE connection with context.
+func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *sseobjectservice.SSEObjectMethodResult) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	var data string
+	byts, err := json.Marshal(res)
+	if err != nil {
+		return err
+	}
+	data = string(byts)
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSEObjectMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/golden/sse-request-id.golden b/http/codegen/testdata/golden/sse-request-id.golden
new file mode 100644
index 00000000..96890d35
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-request-id.golden
@@ -0,0 +1,51 @@
+// SSERequestIDMethodServerStream implements the
+// sserequestidservice.SSERequestIDMethodServerStream interface using
+// Server-Sent Events.
+type SSERequestIDMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of "string" to the "SSERequestIDMethod" endpoint
+// SSE connection.
+func (s *SSERequestIDMethodServerStream) Send(v string) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of "string" to the
+// "SSERequestIDMethod" endpoint SSE connection with context.
+func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v string) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	var data string
+	data = res
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSERequestIDMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/golden/sse-string.golden b/http/codegen/testdata/golden/sse-string.golden
new file mode 100644
index 00000000..e013da59
--- /dev/null
+++ b/http/codegen/testdata/golden/sse-string.golden
@@ -0,0 +1,51 @@
+// SSEStringMethodServerStream implements the
+// ssestringservice.SSEStringMethodServerStream interface using Server-Sent
+// Events.
+type SSEStringMethodServerStream struct {
+	// once ensures the headers are written once.
+	once sync.Once
+	// w is the HTTP response writer used to send the SSE events.
+	w http.ResponseWriter
+	// r is the HTTP request.
+	r *http.Request
+}
+
+// Send Send streams instances of "string" to the "SSEStringMethod" endpoint
+// SSE connection.
+func (s *SSEStringMethodServerStream) Send(v string) error {
+	return s.SendWithContext(context.Background(), v)
+}
+
+// SendWithContext SendWithContext streams instances of "string" to the
+// "SSEStringMethod" endpoint SSE connection with context.
+func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v string) error {
+	s.once.Do(func() {
+		header := s.w.Header()
+		if header.Get("Content-Type") == "" {
+			header.Set("Content-Type", "text/event-stream")
+		}
+		if header.Get("Cache-Control") == "" {
+			header.Set("Cache-Control", "no-cache")
+		}
+		if header.Get("Connection") == "" {
+			header.Set("Connection", "keep-alive")
+		}
+		s.w.WriteHeader(http.StatusOK)
+	})
+	res := v
+
+	var data string
+	data = res
+	fmt.Fprintf(s.w, "data: %s\n\n", data)
+
+	if f, ok := s.w.(http.Flusher); ok {
+		f.Flush()
+	}
+	return nil
+}
+
+// Close is a no-op for SSE. We keep the method for compatibility with other
+// stream types.
+func (s *SSEStringMethodServerStream) Close() error {
+	return nil
+}
diff --git a/http/codegen/testdata/sse_dsls.go b/http/codegen/testdata/sse_dsls.go
new file mode 100644
index 00000000..d7de8bbf
--- /dev/null
+++ b/http/codegen/testdata/sse_dsls.go
@@ -0,0 +1,142 @@
+package testdata
+
+import (
+	. "goa.design/goa/v3/dsl"
+)
+
+var SSEStringDSL = func() {
+	Service("SSEStringService", func() {
+		Method("SSEStringMethod", func() {
+			StreamingResult(String)
+			HTTP(func() {
+				GET("/string")
+				ServerSentEvents()
+			})
+		})
+	})
+}
+
+var SSEIntDSL = func() {
+	Service("SSEIntService", func() {
+		Method("SSEIntMethod", func() {
+			StreamingResult(Int)
+			HTTP(func() {
+				GET("/int")
+				ServerSentEvents()
+			})
+		})
+	})
+}
+
+var SSEBoolDSL = func() {
+	Service("SSEBoolService", func() {
+		Method("SSEBoolMethod", func() {
+			StreamingResult(Boolean)
+			HTTP(func() {
+				GET("/bool")
+				ServerSentEvents()
+			})
+		})
+	})
+}
+
+var SSEObjectDSL = func() {
+	Service("SSEObjectService", func() {
+		Method("SSEObjectMethod", func() {
+			StreamingResult(func() {
+				Attribute("id", String)
+				Attribute("value", Int)
+				Attribute("flag", Boolean)
+			})
+			HTTP(func() {
+				GET("/object")
+				ServerSentEvents()
+			})
+		})
+	})
+}
+
+var SSEDataFieldDSL = func() {
+	Service("SSEDataFieldService", func() {
+		Method("SSEDataFieldMethod", func() {
+			StreamingResult(func() {
+				Attribute("data", String)
+				Attribute("flag", Boolean)
+			})
+			HTTP(func() {
+				GET("/data-field")
+				ServerSentEvents("data")
+			})
+		})
+	})
+}
+
+var SSEDataIDFieldDSL = func() {
+	Service("SSEDataIDFieldService", func() {
+		Method("SSEDataIDFieldMethod", func() {
+			StreamingResult(func() {
+				Attribute("data", String)
+				Attribute("id", String)
+			})
+			HTTP(func() {
+				GET("/data-id-field")
+				ServerSentEvents("data", func() {
+					SSEEventID("id")
+				})
+			})
+		})
+	})
+}
+
+var SSERequestIDDSL = func() {
+	Service("SSERequestIDService", func() {
+		Method("SSERequestIDMethod", func() {
+			Payload(func() {
+				Attribute("id", String)
+			})
+			StreamingResult(String)
+			HTTP(func() {
+				GET("/request-id")
+				ServerSentEvents(func() {
+					SSERequestID("id")
+				})
+			})
+		})
+	})
+}
+
+var SSEAllFieldsDSL = func() {
+	Service("SSEAllFieldsService", func() {
+		Method("SSEAllFieldsMethod", func() {
+			Payload(func() {
+				Attribute("id", String)
+			})
+			StreamingResult(func() {
+				Attribute("id", String, func() {
+					Example("123")
+				})
+				Attribute("event", String, func() {
+					Example("update")
+				})
+				Attribute("retry", Int, func() {
+					Example(3000)
+				})
+				Attribute("data", func() {
+					Attribute("message", String, func() {
+						Example("Hello, world!")
+					})
+				})
+			})
+			HTTP(func() {
+				GET("/all-fields")
+				ServerSentEvents(func() {
+					SSERequestID("id")
+					SSEEventID("id")
+					SSEEventType("event")
+					SSEEventRetry("retry")
+					SSEEventData("data")
+				})
+			})
+		})
+	})
+}