Skip to main content

platform_core/automation/
event_api.rs

1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Event over HTTP (increment 61) — Rust port of Java
18//! `org.platformlambda.core.services.EventApiService` + the `EventEmitter`
19//! event-over-http client. A serialized [`EventEnvelope`] (the **standard**
20//! wire format, increment 59) is exchanged over `POST /api/event`, so a
21//! function in one application instance can call a **public** function
22//! (`is_private = false`) in another — the only cross-instance coupling, and
23//! opt-in by design.
24//!
25//! The service is reached through REST automation (the `/api/event` entry
26//! ships in the default `rest.yaml`, merged like the actuators). It decodes
27//! the posted envelope, enforces the visibility boundary (403 for a private
28//! target — a remote caller must never reach engine internals or an
29//! unpublished function), and dispatches: async (`x-async: true`) is
30//! drop-n-forget with a 202 ack; otherwise RPC up to `x-ttl` ms.
31
32use std::collections::HashMap;
33use std::sync::OnceLock;
34use std::time::Duration;
35
36use async_trait::async_trait;
37use rmpv::Value;
38
39use crate::automation::http_client::{AsyncHttpRequest, ASYNC_HTTP_REQUEST};
40use crate::envelope::EventEnvelope;
41use crate::function::{AppError, ComposableFunction};
42use crate::platform::Platform;
43use crate::post_office::PostOffice;
44use crate::util::app_config_reader::AppConfigReader;
45use crate::util::config_reader::ConfigReader;
46use crate::util::multi_level_map::ConfigValue;
47use crate::util::w3c_trace;
48
49/// Route of the event-over-http service (Java `EventApiService.EVENT_API_SERVICE`).
50pub const EVENT_API_SERVICE: &str = "event.api.service";
51
52/// Envelope header marking an event that already crossed an Event-over-HTTP
53/// hop (Java `EventEmitter.X_EVENT_API`) — the recursion guard: the send and
54/// request hooks never re-forward an event carrying it, so a declaratively
55/// routed event crosses the wire exactly once. Visible to the receiving
56/// function like any other envelope header.
57pub const X_EVENT_API: &str = "x-event-api";
58
59const OCTET_STREAM: &str = "application/octet-stream";
60const TEXT_EVENT_STREAM: &str = "text/event-stream";
61const STREAM_CALLER_REQUIRED: &str =
62    "Streaming function requires a caller that accepts text/event-stream";
63const X_TTL: &str = "x-ttl";
64const X_ASYNC: &str = "x-async";
65/// Java `EventEmitter.X_NO_STREAM`: instructs the receiving side to return a
66/// small payload as bytes — part of the /api/event request's wire header set.
67const X_NO_STREAM: &str = "x-small-payload-as-bytes";
68
69/// Application key naming the declarative routing map
70/// (Java `EventEmitter.EVENT_OVER_HTTP_YAML`).
71const EVENT_OVER_HTTP_YAML: &str = "yaml.event.over.http";
72const DEFAULT_EVENT_OVER_HTTP_YAML: &str = "classpath:/event-over-http.yaml";
73
74/// Fixed forward timeout for the send-path (fire-and-forget / callback) hook
75/// (Java `EventEmitter.ASYNC_EVENT_HTTP_TIMEOUT` = 60s).
76const ASYNC_EVENT_HTTP_TIMEOUT: Duration = Duration::from_secs(60);
77
78/// The `/api/event` service (Java `EventApiService`). Registered PRIVATE — it
79/// is reached only through the REST boundary, never as a remote target.
80pub struct EventApiService {
81    platform: Platform,
82}
83
84impl EventApiService {
85    pub fn new(platform: &Platform) -> Self {
86        EventApiService {
87            platform: platform.clone(),
88        }
89    }
90}
91
92#[async_trait]
93impl ComposableFunction for EventApiService {
94    async fn handle_event(
95        &self,
96        _headers: HashMap<String, String>,
97        input: EventEnvelope,
98        _instance: usize,
99    ) -> Result<EventEnvelope, AppError> {
100        // registered as an event interceptor (Java @EventInterceptor parity):
101        // replies are sent manually to the edge's reply route with the edge
102        // context id, so the streaming branch can withhold its auto-reply
103        let Some(reply_to) = input.reply_to().map(str::to_string) else {
104            return Ok(EventEnvelope::new());
105        };
106        let context_id = input.correlation_id().unwrap_or_default().to_string();
107        let po = PostOffice::new(&self.platform);
108        if let Some(response) = self.dispatch(&po, &reply_to, &context_id, input).await? {
109            let _ = po
110                .send(response.set_to(&reply_to).set_correlation_id(&context_id))
111                .await;
112        }
113        Ok(EventEnvelope::new())
114    }
115}
116
117impl EventApiService {
118    /// Decode, validate and dispatch one /api/event call. Returns the reply
119    /// envelope for the single-shot modes, or None when the streaming relay
120    /// rewired the inner request onto the edge's reply lane.
121    async fn dispatch(
122        &self,
123        po: &PostOffice,
124        reply_to: &str,
125        context_id: &str,
126        input: EventEnvelope,
127    ) -> Result<Option<EventEnvelope>, AppError> {
128        let request = AsyncHttpRequest::from_value(input.body());
129        let timeout_ms = request
130            .header(X_TTL)
131            .and_then(|v| v.parse::<u64>().ok())
132            .unwrap_or(0)
133            .max(1000);
134        let is_async = request.header(X_ASYNC) == Some("true");
135        let accepts_sse = request
136            .header("accept")
137            .is_some_and(|accept| accept.contains(TEXT_EVENT_STREAM));
138        // on the streaming-capable path the edge dispatched through a reply
139        // lane (envelope mode) and wraps every single-shot lane reply into the
140        // classic wire itself - so errors ride RAW here, exactly once wrapped;
141        // the other paths pack the classic wire as before
142        let capable = accepts_sse && !is_async;
143        let answer = |status: i32, message: &str| -> EventEnvelope {
144            if capable {
145                EventEnvelope::new()
146                    .set_status(status)
147                    .set_raw_body(Value::from(message))
148            } else {
149                reply(status, error_envelope(status, message))
150            }
151        };
152        // the HTTP body is the serialized envelope; octet-stream arrives as a
153        // MsgPack-binary body on the request map (Java parity)
154        let Value::Binary(bytes) = request.body() else {
155            return Ok(Some(if capable {
156                answer(500, "Invalid event-over-http data format")
157            } else {
158                reply(500, b"Invalid event-over-http data format".to_vec())
159            }));
160        };
161        // v1 accepts the standard wire format only (phase-2 decision); a
162        // compact (all single-char keys) envelope is rejected clearly
163        if is_compact_envelope(bytes) {
164            return Ok(Some(answer(
165                400,
166                "compact format not supported - set event.over.http.format=standard on the sender",
167            )));
168        }
169        let inner = match EventEnvelope::from_bytes(bytes) {
170            Ok(envelope) => envelope,
171            // the format is unknown when decode fails (Java falls back to a
172            // compact error reply; we answer 400 with a plain message)
173            Err(e) => return Ok(Some(answer(400, e.message()))),
174        };
175        // an inbound '@origin' suffix from a legacy/mesh-era peer is parsed
176        // away — this port never generates one (Eric's ruling)
177        let Some(to) = inner
178            .to()
179            .map(|to| crate::platform::bare_route(to).to_string())
180        else {
181            return Ok(Some(answer(400, "Missing routing path")));
182        };
183        // session info injected by an authentication service on this /api/event
184        // entry rides to the target function as read-only headers (Java parity:
185        // sessionInfo.forEach(request::setHeader))
186        let mut inner = inner;
187        for (key, value) in request.session() {
188            inner = inner.set_header(key, value);
189        }
190        if !self.platform.has_route(&to) {
191            return Ok(Some(answer(404, &format!("Route {to} not found"))));
192        }
193        if self.platform.is_private(&to) == Some(true) {
194            return Ok(Some(answer(403, &format!("{to} is private"))));
195        }
196        if is_async {
197            // drop-n-forget: deliver and acknowledge (Java 202 ack shape)
198            po.send(inner).await?;
199            let ack = EventEnvelope::new()
200                .set_status(202)
201                .set_body(serde_json::json!({
202                    "type": "async",
203                    "delivered": true,
204                    "time": crate::trace::iso8601_utc_now(),
205                }))?;
206            Ok(Some(reply(200, ack.to_bytes()?)))
207        } else if accepts_sse {
208            // streaming-capable relay: the edge dispatched this call through a
209            // dedicated reply lane (stream_dispatch, envelope mode) - rewire
210            // the inner request onto that lane so the target streams straight
211            // to it, with the edge context id as the correlation id (exactly
212            // the rewrite the RPC inbox would make). A non-streaming target's
213            // single reply takes the same lane and renders byte-identical to
214            // the classic RPC response.
215            let inner = inner.set_reply_to(reply_to).set_correlation_id(context_id);
216            po.send(inner).await?;
217            Ok(None)
218        } else {
219            // RPC: forward and mirror the target's envelope back (or 408).
220            // A streaming reply cannot ride a single-shot response: answer
221            // with an explicit refusal instead of a truncated first segment.
222            match po.request(inner, Duration::from_millis(timeout_ms)).await {
223                Ok(result) => {
224                    if has_stream_marker(&result) {
225                        Ok(Some(reply(
226                            406,
227                            error_envelope(406, STREAM_CALLER_REQUIRED),
228                        )))
229                    } else {
230                        Ok(Some(reply(200, result.to_bytes()?)))
231                    }
232                }
233                Err(e) => Ok(Some(reply(408, error_envelope(408, e.message())))),
234            }
235        }
236    }
237}
238
239/// True when the envelope carries the reserved x-event-stream marker.
240fn has_stream_marker(event: &EventEnvelope) -> bool {
241    event
242        .headers()
243        .iter()
244        .any(|(name, _)| name.eq_ignore_ascii_case(crate::event_stream::X_EVENT_STREAM))
245}
246
247/// Build the HTTP response envelope: the body is a serialized envelope carried
248/// raw as `application/octet-stream` (Java `sendResponse`/`sendError`). The
249/// outer status is 200 for a successful dispatch (the real result status rides
250/// inside the serialized body) and the error code for a service-level error.
251fn reply(http_status: i32, body: Vec<u8>) -> EventEnvelope {
252    EventEnvelope::new()
253        .set_status(http_status)
254        .set_header("content-type", OCTET_STREAM)
255        .set_raw_body(Value::Binary(body))
256}
257
258/// A serialized error envelope (status + message body) — the payload the
259/// client deserializes and hands back to its caller.
260fn error_envelope(status: i32, message: &str) -> Vec<u8> {
261    EventEnvelope::new()
262        .set_status(status)
263        .set_raw_body(Value::from(message))
264        .to_bytes()
265        .unwrap_or_default()
266}
267
268/// A compact (legacy Java) envelope has ONLY single-character top-level keys;
269/// the standard format's keys are all longer, so the namespaces are disjoint.
270fn is_compact_envelope(bytes: &[u8]) -> bool {
271    match rmp_serde::from_slice::<Value>(bytes) {
272        Ok(Value::Map(entries)) if !entries.is_empty() => entries
273            .iter()
274            .all(|(k, _)| k.as_str().is_some_and(|s| s.chars().count() == 1)),
275        _ => false,
276    }
277}
278
279/// Event-over-http client (Java `EventEmitter.asyncRequest`/`eRequest` over an
280/// endpoint): POST `event` to `{endpoint}` as a serialized standard envelope.
281/// `rpc = true` awaits the target's reply up to `timeout`; `rpc = false` is
282/// drop-n-forget (the 202 ack envelope is returned). Trace context propagates
283/// via `x-trace-id` + W3C `traceparent` so cross-language traces chain.
284pub async fn event_over_http(
285    po: &PostOffice,
286    endpoint: &str,
287    event: EventEnvelope,
288    timeout: Duration,
289    rpc: bool,
290) -> Result<EventEnvelope, AppError> {
291    static NO_HEADERS: OnceLock<HashMap<String, String>> = OnceLock::new();
292    event_over_http_with_headers(
293        po,
294        endpoint,
295        event,
296        timeout,
297        rpc,
298        NO_HEADERS.get_or_init(HashMap::new),
299    )
300    .await
301}
302
303/// [`event_over_http`] with additional per-call HTTP headers — the carrier of
304/// the per-target security headers (e.g. `authorization`) declared in
305/// `yaml.event.over.http` (Java `EventEmitter.asyncRequest(event, timeout,
306/// headers, endpoint, rpc)`).
307pub async fn event_over_http_with_headers(
308    po: &PostOffice,
309    endpoint: &str,
310    event: EventEnvelope,
311    timeout: Duration,
312    rpc: bool,
313    security_headers: &HashMap<String, String>,
314) -> Result<EventEnvelope, AppError> {
315    let (host, path) = split_endpoint(endpoint)?;
316    // stamp the calling function's trace context onto the WIRE envelope
317    // (fill-if-absent trace id/path; the caller's span unconditionally) —
318    // Java parity: the trace-aware po.request(..., endpoint, rpc) touches the
319    // event before serialization, so the remote function parents onto the
320    // caller's span. The declarative hook already applied this in request();
321    // a second application is idempotent. A PROGRAMMATIC caller passing a
322    // fresh envelope gets the same lineage automatically.
323    let event = crate::post_office::apply_current_trace(event);
324    let trace_id = event.trace_id().map(str::to_string);
325    let span_id = event.span_id().map(str::to_string);
326    let payload = event.to_bytes()?;
327    let mut http = AsyncHttpRequest::new()
328        .set_method("POST")
329        .set_url(&path)
330        .set_target_host(&host)
331        .set_header("content-type", OCTET_STREAM)
332        // Java EventEmitter wire parity: both engines' /api/event requests
333        // carry the same header set (x-small-payload-as-bytes + accept)
334        .set_header(X_NO_STREAM, "true")
335        .set_header("accept", "*/*")
336        .set_header(X_TTL, &timeout.as_millis().max(1000).to_string())
337        // a client-side instruction, consumed by the async HTTP client and
338        // never sent to the peer (the x-event-format precedent in Java):
339        // this is the engine's own Event-over-HTTP transport leg, so the
340        // client must not stamp the business correlation-id header on it —
341        // the business cid rides INSIDE the envelope (my_cid tag; Java's
342        // EventEmitter leg carries no ambient business context)
343        .set_header(X_EVENT_API, "true")
344        .set_body(Value::Binary(payload));
345    if !rpc {
346        http = http.set_header(X_ASYNC, "true");
347    }
348    // per-target security headers ride the HTTP call; the framework headers
349    // above stay authoritative on a name clash (first match wins on read)
350    for (key, value) in security_headers {
351        http = http.set_header(key, value);
352    }
353    http = stamp_trace_headers(http, trace_id.as_deref(), span_id.as_deref());
354    let http_event = EventEnvelope::new()
355        .set_to(ASYNC_HTTP_REQUEST)
356        .set_raw_body(http.to_value());
357    // the local wait gets a small grace over the remote TTL (Java parity:
358    // EventEmitter's inner deadline is timeout + 100ms) so a peer that spends
359    // its whole TTL still replies in-band — its 408 envelope must win the
360    // race against the local abort, never lose it. This internal RPC bypasses
361    // the declarative hook (request_direct) — the HTTP client leg of a
362    // forward must never consult the registry itself.
363    let response = po
364        .request_direct(http_event, timeout + Duration::from_millis(100))
365        .await?;
366    // the response body is the serialized reply envelope (octet-stream →
367    // binary); a non-envelope response — e.g. an authentication-layer 401 in
368    // the REST error JSON shape, produced before the Event API service ever
369    // ran — is returned as-is with its HTTP status (Java parity:
370    // EventEmitter.handleFutureResponse)
371    match response.body() {
372        Value::Binary(bytes) => EventEnvelope::from_bytes(bytes),
373        _ => Ok(response),
374    }
375}
376
377/// Trace propagation of an Event-over-HTTP call (Java sets both headers so the
378/// receiver chains onto this span as its parent): `x-trace-id` plus the W3C
379/// `traceparent`. When a custom traceparent header name is configured
380/// (`http.traceparent.header`), the same value is stamped under that name too,
381/// so the trace context survives an intermediary that strips the standard header.
382fn stamp_trace_headers(
383    mut http: AsyncHttpRequest,
384    trace_id: Option<&str>,
385    span_id: Option<&str>,
386) -> AsyncHttpRequest {
387    if let Some(trace_id) = trace_id {
388        http = http.set_header("x-trace-id", trace_id);
389        if let Some(span_id) = span_id {
390            if let Some(traceparent) = w3c_trace::format(trace_id, span_id) {
391                http = http.set_header(w3c_trace::TRACEPARENT, &traceparent);
392                let custom_traceparent = AppConfigReader::get_instance()
393                    .get_property_or("http.traceparent.header", w3c_trace::TRACEPARENT);
394                if !custom_traceparent.eq_ignore_ascii_case(w3c_trace::TRACEPARENT) {
395                    http = http.set_header(&custom_traceparent, &traceparent);
396                }
397            }
398        }
399    }
400    http
401}
402
403/// Split `http://host:port/api/event` into (`http://host:port`, `/api/event`).
404fn split_endpoint(endpoint: &str) -> Result<(String, String), AppError> {
405    let scheme_end = endpoint
406        .find("://")
407        .map(|i| i + 3)
408        .ok_or_else(|| AppError::new(400, format!("Invalid endpoint {endpoint}")))?;
409    match endpoint[scheme_end..].find('/') {
410        Some(offset) => {
411            let split = scheme_end + offset;
412            Ok((endpoint[..split].to_string(), endpoint[split..].to_string()))
413        }
414        None => Ok((endpoint.to_string(), "/api/event".to_string())),
415    }
416}
417
418// ---- declarative Event over HTTP (Java `yaml.event.over.http`) ----
419
420/// One declarative routing entry: the peer's `/api/event` URL plus optional
421/// per-target security headers (Java `EventEmitter.eventHttpTargets` +
422/// `eventHttpHeaders`, folded into one struct).
423#[derive(Debug)]
424pub struct EventHttpTarget {
425    /// The peer endpoint, e.g. `http://127.0.0.1:8085/api/event`.
426    pub target: String,
427    /// HTTP headers added to every forwarded call (e.g. `authorization`).
428    pub headers: HashMap<String, String>,
429}
430
431/// The route → target map, loaded once on first use (Java loads it in the
432/// `EventEmitter` constructor; the Rust port has no such singleton, so the
433/// registry initializes lazily from the same configuration).
434fn event_http_registry() -> &'static HashMap<String, EventHttpTarget> {
435    static REGISTRY: OnceLock<HashMap<String, EventHttpTarget>> = OnceLock::new();
436    REGISTRY.get_or_init(load_event_http_routes)
437}
438
439/// Declarative Event-over-HTTP lookup (Java `EventEmitter.getEventHttpTarget`
440/// and `getEventHttpHeaders`): the configured target for a route, with any
441/// `@instance` suffix stripped for the lookup. `None` = the route is local.
442pub fn get_event_http_target(route: &str) -> Option<&'static EventHttpTarget> {
443    let base = match route.find('@') {
444        Some(at) => &route[..at],
445        None => route,
446    };
447    event_http_registry().get(base)
448}
449
450/// Load the `event.http[]` entries from the file named by
451/// `yaml.event.over.http` (default `classpath:/event-over-http.yaml`).
452/// An absent file simply disables the feature; `${...}` references in the
453/// values (environment variables, base configuration keys) resolve at load
454/// time (Java `EventEmitter.loadHttpRoutes`).
455fn load_event_http_routes() -> HashMap<String, EventHttpTarget> {
456    let mut targets = HashMap::new();
457    let explicit = AppConfigReader::get_instance().get_property(EVENT_OVER_HTTP_YAML);
458    let path = explicit
459        .clone()
460        .unwrap_or_else(|| DEFAULT_EVENT_OVER_HTTP_YAML.to_string());
461    let reader = match ConfigReader::load(&path) {
462        Ok(reader) => reader,
463        Err(e) => {
464            // only an explicitly configured file is worth an error — the
465            // default location is optional by design
466            if explicit.is_some() {
467                log::error!("Unable to load event-over-http config - {e}");
468            }
469            return targets;
470        }
471    };
472    let Some(ConfigValue::List(entries)) = reader.get("event.http") else {
473        log::error!(
474            "Invalid config {path} - the event.http section should be a list of route and target"
475        );
476        return targets;
477    };
478    for i in 0..entries.len() {
479        let route = reader
480            .get_property(&format!("event.http[{i}].route"))
481            .unwrap_or_default();
482        let target = reader
483            .get_property(&format!("event.http[{i}].target"))
484            .unwrap_or_default();
485        if route.is_empty() || target.is_empty() {
486            continue;
487        }
488        if crate::platform::validate_route(&route).is_err() {
489            log::error!("Invalid Event over HTTP config entry - check route {route}");
490            continue;
491        }
492        if split_endpoint(&target).is_err() {
493            log::error!("Invalid Event over HTTP config entry - check target {target}");
494            continue;
495        }
496        let mut headers = HashMap::new();
497        if let Some(ConfigValue::Map(map)) = reader.get(&format!("event.http[{i}].headers")) {
498            for key in map.keys() {
499                if let Some(value) = reader.get_property(&format!("event.http[{i}].headers.{key}"))
500                {
501                    headers.insert(key.clone(), value);
502                }
503            }
504        }
505        log::info!(
506            "Event-over-HTTP {route} -> {target} with {} header{}",
507            headers.len(),
508            if headers.len() == 1 { "" } else { "s" }
509        );
510        targets.insert(route, EventHttpTarget { target, headers });
511    }
512    log::info!(
513        "Total {} event-over-http target{} configured",
514        targets.len(),
515        if targets.len() == 1 { "" } else { "s" }
516    );
517    targets
518}
519
520/// The send-path forward (Java `EventEmitter.sendWithEventHttp`): an event
521/// whose route is declaratively mapped crosses to the peer instead of the
522/// local bus. With a `reply_to` it is a **callback**: the reply address is
523/// withheld from the wire, the forward runs as RPC, and the peer's response
524/// is delivered to the original `reply_to` locally (restoring `from`, trace,
525/// and the business correlation-id). Without one it is **async**: forwarded
526/// drop-n-forget, expecting the peer's 202 ack. Both run detached — like
527/// Java, `send` returns as soon as the forward is scheduled.
528pub(crate) fn send_with_event_http(
529    platform: &Platform,
530    event: EventEnvelope,
531    to: &str,
532    entry: &'static EventHttpTarget,
533) -> Result<(), AppError> {
534    let callback = event.reply_to().map(str::to_string);
535    if let Some(callback) = &callback {
536        if accepts_event_stream_header(&event) {
537            // the event-level opt-in for progressive streaming over
538            // Event-over-HTTP: "accept: text/event-stream"
539            return relay_event_stream(platform, event, to, entry, callback.clone());
540        }
541    }
542    let event_api_type = if callback.is_some() {
543        "callback"
544    } else {
545        "async"
546    };
547    let trace_id = event.trace_id().map(str::to_string);
548    let trace_path = event.trace_path().map(str::to_string);
549    let cid = event.correlation_id().map(str::to_string);
550    let forward = event
551        .clear_reply_to()
552        .set_header(X_EVENT_API, event_api_type);
553    let platform = platform.clone();
554    let to = to.to_string();
555    tokio::spawn(async move {
556        let po = PostOffice::new(&platform);
557        let outcome = event_over_http_with_headers(
558            &po,
559            &entry.target,
560            forward,
561            ASYNC_EVENT_HTTP_TIMEOUT,
562            callback.is_some(),
563            &entry.headers,
564        )
565        .await;
566        match outcome {
567            Ok(reply) => {
568                if let Some(callback) = callback {
569                    // deliver the peer's response to the original reply_to
570                    // locally, restoring sender, trace, and correlation-id
571                    let mut response = reply.set_to(&callback).clear_reply_to().set_from(&to);
572                    if let (Some(id), Some(path)) = (&trace_id, &trace_path) {
573                        response = response.set_trace(id, path);
574                    }
575                    if let Some(cid) = &cid {
576                        response = response.set_correlation_id(cid);
577                    }
578                    if let Err(e) = po.send(response).await {
579                        log::error!(
580                            "Error in sending callback event {to} from {} to {callback} - {}",
581                            entry.target,
582                            e.message()
583                        );
584                    }
585                } else if reply.status() != 202 {
586                    log::error!(
587                        "Error in sending async event {to} to {} - status={}, error={}",
588                        entry.target,
589                        reply.status(),
590                        reply
591                            .body_as::<String>()
592                            .unwrap_or_else(|_| format!("{}", reply.body()))
593                    );
594                }
595            }
596            Err(e) => {
597                log::error!(
598                    "Error in sending event {to} to {} - {}",
599                    entry.target,
600                    e.message()
601                );
602            }
603        }
604    });
605    Ok(())
606}
607
608/// The event-level opt-in for progressive streaming over Event-over-HTTP:
609/// the outbound event declares the header `accept: text/event-stream`.
610fn accepts_event_stream_header(event: &EventEnvelope) -> bool {
611    event.headers().iter().any(|(name, value)| {
612        name.eq_ignore_ascii_case("accept") && value.contains(TEXT_EVENT_STREAM)
613    })
614}
615
616/// Streaming-capable Event-over-HTTP relay (callback mode with the accept
617/// opt-in, Java `EventEmitter.relayEventStream`): the POST advertises
618/// `Accept: text/event-stream` and the caller's reply route and correlation id
619/// pass through to the HTTP client, which consumes the peer's SSE response
620/// progressively (the envelope-mode wire dialect) and forwards each decoded
621/// event to the callback. A peer that answers single-shot - a non-streaming
622/// target, or an older engine - falls back to the classic callback delivery.
623/// The event's `x-ttl` header (milliseconds, default 60 seconds) is the idle
624/// allowance between stream events on both hops.
625fn relay_event_stream(
626    platform: &Platform,
627    event: EventEnvelope,
628    to: &str,
629    entry: &'static EventHttpTarget,
630    callback: String,
631) -> Result<(), AppError> {
632    let ttl_ms = event
633        .headers()
634        .iter()
635        .find(|(name, _)| name.eq_ignore_ascii_case(X_TTL))
636        .and_then(|(_, value)| value.trim().parse::<u64>().ok())
637        .map_or(ASYNC_EVENT_HTTP_TIMEOUT.as_millis() as u64, |v| v.max(1000));
638    // the wire envelope carries the caller's trace lineage (fill-if-absent id
639    // and path, the caller's span) so the remote function parents onto it
640    let forward = crate::post_office::apply_current_trace(
641        event
642            .clear_reply_to()
643            .set_header(X_EVENT_API, crate::automation::http_client::STREAM_RELAY),
644    );
645    let cid = forward.correlation_id().map(str::to_string);
646    let trace_id = forward.trace_id().map(str::to_string);
647    let trace_path = forward.trace_path().map(str::to_string);
648    let span_id = forward.span_id().map(str::to_string);
649    let platform = platform.clone();
650    let to = to.to_string();
651    tokio::spawn(async move {
652        let (host, path) = match split_endpoint(&entry.target) {
653            Ok(parts) => parts,
654            Err(e) => {
655                log::error!(
656                    "Unable to relay event stream {to} to {} - {}",
657                    entry.target,
658                    e.message()
659                );
660                return;
661            }
662        };
663        let payload = match forward.to_bytes() {
664            Ok(bytes) => bytes,
665            Err(e) => {
666                log::error!(
667                    "Unable to relay event stream {to} to {} - {}",
668                    entry.target,
669                    e.message()
670                );
671                return;
672            }
673        };
674        let mut http = AsyncHttpRequest::new()
675            .set_method("POST")
676            .set_url(&path)
677            .set_target_host(&host)
678            .set_header("content-type", OCTET_STREAM)
679            .set_header(X_NO_STREAM, "true")
680            .set_header("accept", TEXT_EVENT_STREAM)
681            .set_header(X_TTL, &ttl_ms.to_string())
682            // the engine's own transport leg: the client must not stamp the
683            // business correlation-id header on it (event_over_http parity)
684            .set_header(X_EVENT_API, "true")
685            .set_body(Value::Binary(payload));
686        for (key, value) in &entry.headers {
687            http = http.set_header(key, value);
688        }
689        http = stamp_trace_headers(http, trace_id.as_deref(), span_id.as_deref());
690        let mut http_event = EventEnvelope::new()
691            .set_to(ASYNC_HTTP_REQUEST)
692            .set_raw_body(http.to_value())
693            .set_reply_to(&callback)
694            .set_from(&to)
695            .set_header(X_EVENT_API, crate::automation::http_client::STREAM_RELAY);
696        if let Some(cid) = &cid {
697            http_event = http_event.set_correlation_id(cid);
698        }
699        if let (Some(id), Some(path)) = (&trace_id, &trace_path) {
700            http_event = http_event.set_trace(id, path);
701        }
702        let po = PostOffice::new(&platform);
703        if let Err(e) = po.send(http_event).await {
704            log::error!(
705                "Unable to relay event stream {to} to {} - {}",
706                entry.target,
707                e.message()
708            );
709        }
710    });
711    Ok(())
712}