Server-Sent Events - real world implementation

In SAP Commerce, frontend applications often retrieve the latest data by periodically requesting updates from the server. For instance, this could involve checking for new notifications every few seconds or refreshing a page every 30 seconds to display the most recent content. While straightforward to implement, this approach has notable drawbacks, including increased server load, unnecessary network traffic, and a suboptimal user experience due to update delays.

Luckily, Server-Sent Events (SSE) offer a more efficient alternative. With SSE, the backend can push updates to the frontend in real-time, eliminating the need for polling. This improves performance and provides users with a more seamless and up-to-date experience.

Problem Description

Most tutorials on server-sent events (SSE) focus on the basics: setting up a simple Spring MVC controller, creating a streaming endpoint, and pushing data to the frontend. These examples are fine for toy projects but fall short in real-world scenarios.

Enterprise-grade projects like those built on SAP Commerce demand much more. Real-world scenarios involve integrating SSE into large, modular codebases, handling high concurrency, ensuring secure authorization, and adhering to strict performance requirements. These challenges are rarely covered in tutorials, which don’t even mention that SseEmitter.SseEventBuilder implementation is not thread-safe.

Additionally, SAP Commerce introduces its own set of constraints. For SSE to function properly in Spring MVC, async request processing must be enabled in the servlet. This is typically done by adjusting the web.xml configuration. However, in SAP Commerce’s OOTB setup, async support is not enabled in the commercewebservices extension’s servlet definitions and can’t be adjusted as it is an OOTB read only file, what prevents direct adoption of this approach.

Technical Solution

In the nutshell Server-Sent Events (SSE) is long-living GET request, into which BE writes data(events or messages) in special format. Technical details can be found at HTML Server-Sent Events Standard. As a basis of implementation would be used SseEmitter, provided by Spring MVC framework, which is a kind of wrapper for long-lived Request/Response, which allows to write data in Response at any time, so it is transmitted to FE in real-time. Instances of SseEmitter must be stored in application memory, as they represent Response to FE application (holds connection to FE client), in which backend must write data.

New asyncwebservices web extension should be introduced to create a Servlet with async support and all SSE related implementation. To decouple SSE implementation from other code base and ensure that other extensions, would not depend on asyncwebservices extensions, would be used Spring Event system as an intermediate message bus instead of direct invocation of SSE service methods. SseCompatibleSpringEvent interface should be introduced in core extension and used in asyncwebservices extension to be able to differentiate between regular events and events, which should be sent to FE as SSE.

SSE Sap Commerce C4 Diagram

The implementation consists of several key components. The ServerSentEventsController acts as the entry point for the frontend, exposing an endpoint where clients can establish SSE connections. It delegates the creation and management of SseEmitter instances to SseEmitterService, which is responsible for registering new subscribers and sending events.

To efficiently store and manage active connections, the SseEmitterStorage component is introduced, mapping each user to their active SseEmitter instances. SseStorageResolver is used to separate anonymous users from logged-in users to ensure that events would be delivered to proper users. Meanwhile, domain events that need to be published as SSE messages are captured by the SseCompatibleEventListener, which listens for relevant Spring Events, converts them to SseEvent, and forwards them using SseEventSendService.

Implementation Details

SseCompatibleSpringEvent

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.blog.core.sse;

import de.hybris.platform.servicelayer.event.ClusterAwareEvent;

import java.io.Serializable;
import java.util.List;

public interface SseCompatibleSpringEvent extends ClusterAwareEvent {

    enum SsePublishType {BROADCAST, EVERYONE, ID}

    enum SseSubscriberType {ANONYMOUS, AUTHENTICATED}

    record SsePublishOptions(
            SsePublishType publishType,
            SseSubscriberType subscriberType,
            List<String> ids
    ) implements Serializable {
    }

    default Object getSseData() {
        return null;
    }

    default SsePublishOptions getSeePublishOptions() {
        return new SsePublishOptions(SsePublishType.ID, SseSubscriberType.AUTHENTICATED, List.of());
    }

}

The SseCompatibleSpringEvent interface defines a standard structure for events that should be transmitted via SSE. It includes options to specify whether an event should be broadcasted to all users or targeted to specific users(anonymous or logged-in). This interface is used inside service layer to mark regular Spring Event, as event, which should be sent to FE via SSE. To not introduce circular dependencies between extensions, this interface should be a part of core extension.

SseCompatibleSpringEvent interface extends ClusterAwareEvent, which is required to properly work in multi-node environments to support cases, when SSE connection was made on one node and spring event with BE data update is triggered from another node (for example, background processing).

New asyncwebservices extension

First of all should be created a new asyncwebservices extension from ywebservices template. And web.xml should be adjusted to include <async-supported>true</async-supported> in all filters and servlet definitions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<?xml version="1.0" encoding="iso-8859-1"?>
<!--
 Copyright (c) 2020 SAP SE or an SAP affiliate company. All rights reserved.
-->
<web-app id="asyncwebservices" version="3.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://java.sun.com/xml/ns/javaee"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         metadata-complete="true">
    <absolute-ordering/>

    <display-name>asyncwebservices</display-name>

    <filter>
        <filter-name>XSSFilter</filter-name>
        <filter-class>de.hybris.platform.servicelayer.web.XSSFilter</filter-class>
        <async-supported>true</async-supported>
    </filter>

    <filter-mapping>
        <filter-name>XSSFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>characterEncodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <async-supported>true</async-supported>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>forceEncoding</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>

    <filter>
        <filter-name>SessionHidingRequestFilter</filter-name>
        <filter-class>de.hybris.platform.webservicescommons.filter.SessionHidingFilter</filter-class>
        <async-supported>true</async-supported>
    </filter>

    <filter-mapping>
        <filter-name>SessionHidingRequestFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>asyncwebservicesPlatformFilterChain</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
        <async-supported>true</async-supported>
    </filter>

    <filter-mapping>
        <filter-name>characterEncodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter-mapping>
        <filter-name>asyncwebservicesPlatformFilterChain</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <servlet>
        <servlet-name>springmvc</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
        <async-supported>true</async-supported>
    </servlet>

    <servlet-mapping>
        <servlet-name>springmvc</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>WEB-INF/asyncwebservices-web-spring.xml</param-value>
    </context-param>

    <context-param>
        <param-name>contextInitializerClasses</param-name>
        <param-value>
            de.hybris.platform.webservicescommons.initializer.HybrisPropertiesWebApplicationContextInitializer
        </param-value>
    </context-param>

    <listener>
        <listener-class>de.hybris.platform.spring.HybrisContextLoaderListener</listener-class>
    </listener>

    <listener>
        <listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
    </listener>

</web-app>

This is required step, otherwise you would hit java.lang.IllegalStateException: Async support must be enabled on a servlet.

To enable swagger documentation <requires-extension name="swaggerintegration"/> must be added into extensioninfo.xml. After that documentation would be available on https://localhost:9002/asyncwebservices/swagger-ui/index.html

ServerSentEventsController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.blog.async.controllers.sse;

import com.blog.async.controllers.BaseController;
import com.blog.async.sse.services.SseEmitterService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Validator;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;

@Controller
@Tag(name = "SSE")
public class ServerSentEventsController extends BaseController {

    private static final Logger LOG = LoggerFactory.getLogger(ServerSentEventsController.class);

    @Resource
    private Validator sseClientIdValidator;

    @Resource
    private SseEmitterService sseEmitterService;

    @GetMapping(path = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @Operation(
            summary = "Subscribe on SSE",
            description = "Subscribes on Server-Sent Events for anonymous and logged in users."
    )
    public SseEmitter subscribe(
            @Parameter(description = "Optional parameter for logged in users, but it is mandatory for anonymous users.")
            @RequestParam(required = false, defaultValue = "")
            String cid
    ) {
        validate(cid, "clientId", sseClientIdValidator);

        SseEmitter emitter = sseEmitterService.create(cid);
        if (emitter == null) {
            throw new IllegalArgumentException("Server-Sent Events subscription failed.");
        }
        return emitter;
    }


    @ExceptionHandler
    public void exceptionHandler(AsyncRequestTimeoutException e) {
        // ! On request timeout we want close it. FE would automatically reconnect according to specification.
        if (LOG.isDebugEnabled()) {
            LOG.debug("Exception:", e);
        }
    }

}

ServerSentEventsController is main entrypoint for FE to subscribe on push messages from BE, which uses SseEmitterService to create instances of SseEmitter. It supports both anonymous users and logged-in users. To support sending messages for particular anonymous users FE should provide an uniquer identifier, which can be randomly generated and stored in browser session storage. Or it can be a unique identifier of process, which is triggered by anonymous user. For example, anonymous user can trigger PDF generation cronjob, and create SSE connection with cronjob id, so when cronjob would finish it can send message to FE with link for downloading PDF file.

In Appendix you can find an example implementation of sseClientIdValidator, which ensures that anonymous users always send clientId parameter, while logged-in users never send it.

SseEmitterService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package com.blog.async.sse.services.impl;

import com.blog.async.sse.converter.SseEmitterInitEventConverter;
import com.blog.async.sse.domain.SseEvent;
import com.blog.async.sse.services.SseEmitterService;
import com.blog.async.sse.services.SseEventSendService;
import com.blog.async.sse.storage.SseEmitterStorage;
import com.blog.async.sse.storage.SseStorageResolver;
import com.blog.core.sse.SseCompatibleSpringEvent;
import de.hybris.platform.core.model.user.UserModel;
import de.hybris.platform.servicelayer.user.UserService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Nullable;
import javax.annotation.Resource;
import java.util.List;

public class DefaultSseEmitterService implements SseEmitterService {

    private static final Logger LOG = LoggerFactory.getLogger(DefaultSseEmitterService.class);

    @Resource
    private UserService userService;

    @Resource
    private SseStorageResolver sseStorageResolver;

    @Resource
    private SseEventSendService sseEventSendService;

    @Autowired(required = false)
    private List<SseEmitterInitEventConverter> sseEmitterInitEventConverters;

    @Nullable
    @Override
    public SseEmitter create(@Nullable String id) {
        UserModel currentUser = userService.getCurrentUser();

        SseEmitter emitter = createEmitter(currentUser, id);
        publishInitialEvents(emitter, currentUser, id);
        return emitter;

    }

    private SseEmitter createEmitter(UserModel currentUser, String id) {
        SseCompatibleSpringEvent.SseSubscriberType subscriberType = resolveSubscriberType(currentUser);
        if (subscriberType == SseCompatibleSpringEvent.SseSubscriberType.ANONYMOUS && StringUtils.isBlank(id)) {
            LOG.warn("ID must be provided for anonymous user to create SseEmitter.");
            return null;
        }

        SseEmitterStorage sseEmitterStorage = sseStorageResolver.resolveStorage(subscriberType);
        return sseEmitterStorage.create(createEmitterId(subscriberType, currentUser, id));
    }

    private void publishInitialEvents(SseEmitter emitter, UserModel currentUser, String id) {
        if (emitter == null || sseEmitterInitEventConverters == null) {
            return;
        }
        for (SseEmitterInitEventConverter initEventResolver : sseEmitterInitEventConverters) {
            SseEvent event = initEventResolver.convert(currentUser, id);
            if (event != null) {
                sseEventSendService.sendEvent(emitter, event);
            }
        }
    }

    private SseCompatibleSpringEvent.SseSubscriberType resolveSubscriberType(UserModel user) {
        if (userService.isAnonymousUser(user)) {
            return SseCompatibleSpringEvent.SseSubscriberType.ANONYMOUS;
        }
        return SseCompatibleSpringEvent.SseSubscriberType.AUTHENTICATED;
    }

    private String createEmitterId(SseCompatibleSpringEvent.SseSubscriberType subscriberType, UserModel user, String id) {
        if (subscriberType == SseCompatibleSpringEvent.SseSubscriberType.AUTHENTICATED) {
            return user.getUid();
        }
        return id;
    }

}

SseEmitterService is used to create instance of SseEmitter and register them in SseEmitterStorage. On creation ServerSentEventService#create emitter is automatically registered in appropriate SseEmitterStorage and initial events/messages are send to FE right after creation using SseEventSendService. Initial events/messages are retrieved from List<SseEmitterInitEventConverter>.

SseStorageResolver

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.blog.async.sse.storage.impl;

import com.blog.async.sse.storage.SseEmitterStorage;
import com.blog.async.sse.storage.SseStorageResolver;
import com.blog.core.sse.SseCompatibleSpringEvent;

import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;

public class DefaultSseStorageResolver implements SseStorageResolver {

    @Resource
    private SseEmitterStorage authenticatedUserMapSseEmitterStorage;

    @Resource
    private SseEmitterStorage anonymousUserMapSseEmitterStorage;

    @Override
    public Collection<SseEmitterStorage> getAllStorages() {
        return List.of(anonymousUserMapSseEmitterStorage, authenticatedUserMapSseEmitterStorage);
    }

    @Override
    public SseEmitterStorage resolveStorage(SseCompatibleSpringEvent.SseSubscriberType sseSubscriberType) {
        if (sseSubscriberType == SseCompatibleSpringEvent.SseSubscriberType.AUTHENTICATED) {
            return authenticatedUserMapSseEmitterStorage;
        }
        return anonymousUserMapSseEmitterStorage;
    }

}

SseStorageResolver is used to physically separate logged-in and anonymous users, together with SseClientIdValidator it allows to ensure that anonymous users would not be able to crack clientId and subscribe on events of logged-in users to receive some user sensitive information.

SseEmitterStorage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.blog.async.sse.storage.impl;

import com.blog.async.sse.storage.SseEmitterStorage;
import org.apache.commons.lang.StringUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;

public class ConcurrentMapSseEmitterStorage implements SseEmitterStorage {

    private final Map<String, Queue<SseEmitter>> emitterMap = new ConcurrentHashMap<>();

    @Nullable
    @Override
    public SseEmitter create(String id) {
        if (StringUtils.isBlank(id)) {
            return null;
        }

        SseEmitter emitter = createEmitterInstance(id);

        Queue<SseEmitter> emitterQueue = emitterMap.computeIfAbsent(id, k -> new ConcurrentLinkedQueue<>());
        emitterQueue.add(emitter);

        return emitter;
    }

    @NotNull
    @Override
    public Collection<SseEmitter> getAll() {
        return emitterMap.values().stream()
                .flatMap(Collection::stream)
                .toList();
    }

    @NotNull
    @Override
    public Collection<SseEmitter> get(String id) {
        Queue<SseEmitter> emitters = emitterMap.get(id);

        if (emitters == null) {
            return List.of();
        }
        return List.copyOf(emitters);
    }

    private void removeEmitter(String key, SseEmitter emitter) {
        Queue<SseEmitter> userEmitters = emitterMap.get(key);
        if (userEmitters == null) {
            return;
        }
        userEmitters.remove(emitter);
        if (userEmitters.isEmpty()) {
            emitterMap.remove(key);
        }
    }

    private SseEmitter createEmitterInstance(String key) {
        // ! 5 minute is good enough timeout for initial implementation
        // ! based on current amount of users and RAM consumption.
        // ! Can be adjusted if needed.
        long timeoutInMillis = TimeUnit.MINUTES.toMillis(5);

        SseEmitter emitter = new SseEmitter(timeoutInMillis);
        emitter.onCompletion(() -> removeEmitter(key, emitter));
        emitter.onTimeout(() -> removeEmitter(key, emitter));
        emitter.onError((e) -> removeEmitter(key, emitter));
        return emitter;
    }

}

SseEmitter is registered in SseEmitterStorage per user or clientId. And in case there is already registered SseEmitter, new SseEmitter should be created and initial events/messages should be resent. This is needed to support login for same user from multiple browsers/tabs/devices.

SseEmitter are created with 5 minute timeout, what would lead to automatic close of connection from server side after timeout time. That allows to free resources on server side after FE client disconnect.

SseEmitterStorage is used to store instances of SseEmitter with possibility to get SseEmitter for particular user, same as receive all registered SseEmitter for broadcast type of events. ConcurrentMapSseEmitterStorage implementation utilizes a ConcurrentHashMap to store ConcurrentLinkedQueue of SseEmitter per user, which allows to deal with multiple concurrent and async requests without needs for explicit synchronization.

SseEventSendService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.blog.async.sse.services.impl;

import com.blog.async.sse.domain.SseEvent;
import com.blog.async.sse.services.SseEventSendService;
import com.blog.async.sse.storage.SseEmitterStorage;
import com.blog.async.sse.storage.SseStorageResolver;
import com.blog.core.concurrent.ThreadExecutorHelper;
import com.blog.core.concurrent.ThreadPoolType;
import com.blog.core.sse.SseCompatibleSpringEvent;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;
import javax.validation.constraints.NotNull;
import java.io.IOException;
import java.util.Collection;

public class DefaultSseEventSendService implements SseEventSendService {

    @Resource
    private ThreadExecutorHelper threadExecutorHelper;

    @Resource
    private SseStorageResolver sseStorageResolver;

    @Override
    public void sendEvent(SseEmitter emitter, @NotNull SseEvent sseEvent) {
        sendEventAsync(emitter, sseEvent);
    }

    @Override
    public void sendEvent(SseCompatibleSpringEvent.SsePublishOptions ssePublishOptions, @NotNull SseEvent sseEvent) {
        switch (ssePublishOptions.publishType()) {
            case ID -> sendEventsToSpecifiedEmitters(ssePublishOptions, sseEvent);
            case BROADCAST -> sendEventToSpecifiedSubscriberTypes(ssePublishOptions, sseEvent);
            case EVERYONE -> sendEventsToEveryone(sseEvent);
        }
    }

    private void sendEventsToSpecifiedEmitters(SseCompatibleSpringEvent.SsePublishOptions ssePublishOptions, SseEvent sseEvent) {
        SseEmitterStorage sseEmitterStorage = sseStorageResolver.resolveStorage(ssePublishOptions.subscriberType());
        ssePublishOptions.ids().parallelStream()
                .flatMap(id -> sseEmitterStorage.get(id).parallelStream())
                .forEach(emitter -> sendEventAsync(emitter, sseEvent));
    }

    private void sendEventToSpecifiedSubscriberTypes(SseCompatibleSpringEvent.SsePublishOptions ssePublishOptions, SseEvent sseEvent) {
        SseEmitterStorage sseEmitterStorage = sseStorageResolver.resolveStorage(ssePublishOptions.subscriberType());
        sseEmitterStorage.getAll().parallelStream().forEach(emitter -> sendEventAsync(emitter, sseEvent));
    }

    private void sendEventsToEveryone(SseEvent sseEvent) {
        Collection<SseEmitterStorage> storages = sseStorageResolver.getAllStorages();
        storages.parallelStream()
                .flatMap(storage -> storage.getAll().parallelStream())
                .forEach(emitter -> sendEventAsync(emitter, sseEvent));
    }

    private void sendEventAsync(SseEmitter emitter, SseEvent sseEvent) {
        threadExecutorHelper.executeAsync(ThreadPoolType.SSE, () -> safeSend(emitter, sseEvent));
    }

    private static void safeSend(SseEmitter emitter, SseEvent sseEvent) {
        try {
            // ! [[SseEventBuilder]] is not thread safe, so we need to create a new instance inside each thread.
            emitter.send(createEventBuilder(sseEvent));
        } catch (IllegalStateException e) {
            emitter.completeWithError(e);
        } catch (IOException e) {
            // Ignore as it would be handled by Spring MVC itself with [[AsyncRequestTimeoutException]].
        }
    }

    private static SseEmitter.SseEventBuilder createEventBuilder(SseEvent sseEvent) {
        return SseEmitter.event()
                .name(sseEvent.name())
                .data(sseEvent.data());
    }

}

SseEventSendService#sendEvent methods are used to send events/messages to FE (via SseEmitter). Both methods receive SseEvent as parameter and utilize separate thread pool for async execution, which is usually needed to not block execution of other requests or service layer methods, as message broadcast can be a time-consuming procedure. parallelStream is utilized to get additional performance improvement, which can be safely used as SseEmitter doesn’t need SAP Commerce session.

SseEventinterface defines methods name and data and is used to represent SSE objects in domain/service logic. SimpleSseEvent is main record implementation. Reference implementation can be found in Appendix

Implementation of async thread pool and ThreadExecutorHelper can be found in article ThreadPool for async non-blocking execution. In some cases, like CronJobs, it could be acceptable to block BE execution threads, so event can be sent directly without async thread pool.

SseCompatibleEventListener

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.blog.async.sse.event.listeners;

import com.blog.async.sse.domain.SseEvent;
import com.blog.async.sse.services.SseEventSendService;
import com.blog.core.sse.SseCompatibleSpringEvent;
import de.hybris.platform.servicelayer.event.events.AbstractEvent;
import de.hybris.platform.servicelayer.event.impl.AbstractEventListener;
import org.springframework.core.convert.converter.Converter;

import javax.annotation.Resource;
import java.util.Map;

public class SseCompatibleEventListener extends AbstractEventListener<AbstractEvent> {

    @Resource
    private Converter<SseCompatibleSpringEvent, ? extends SseEvent> defaultSseEventConverter;

    @Resource
    private Map<Class<? extends AbstractEvent>, Converter<SseCompatibleSpringEvent, ? extends SseEvent>> sseCompatibleSpringEventConverters;

    @Resource
    private SseEventSendService sseEventSendService;

    @Override
    protected void onEvent(AbstractEvent abstractEvent) {
        if (!(abstractEvent instanceof SseCompatibleSpringEvent sseCompatibleSpringEvent)) {
            return;
        }

        SseEvent sseEvent = convert(sseCompatibleSpringEvent);

        sseEventSendService.sendEvent(sseCompatibleSpringEvent.getSeePublishOptions(), sseEvent);
    }

    private SseEvent convert(SseCompatibleSpringEvent sseCompatibleSpringEvent) {
        Converter<SseCompatibleSpringEvent, ? extends SseEvent> converter = sseCompatibleSpringEventConverters.get(
                sseCompatibleSpringEvent.getClass()
        );
        if (converter != null) {
            return converter.convert(sseCompatibleSpringEvent);
        }
        return defaultSseEventConverter.convert(sseCompatibleSpringEvent);
    }
}

To decouple service layer data changes from sending events to FE is used SseCompatibleEventListener, which is regular Spring Event Listener. Implementation filters out Spring Events, which does not implement SseCompatibleSpringEvent interface, converts them into SseEvent, identifies if event should be broadcast or sent to specific user and uses ServerSentEventService for that.

SseCompatibleSpringEvent interface defines default empty implementation for getSseData and getSeePublishOptions methods, which are used to pass additional data and identify if event should be broadcasted or sent to specific users.

SseCompatibleEventListener uses Map to resolve Converter<SseCompatibleSpringEvent, SseEvent> for current Spring Event, if it is not found DefaultSseEventConverter implementation is used as a fallback. DefaultSseEventConverter converts SseCompatibleSpringEvent into SseEvent using Spring Event class simple name as SSE event name and string representation of SseCompatibleSpringEvent#getSseData as SSE event data.

Convertion of Spring Event implementation into SseEvent would contain logic of initial event creation, so there is a sense to have same class implementing SseEmitterInitEventConverter and Converter<AbstractEvent, SseEvent> interfaces, instead of having two separate converters with common logic extracted in Util. This is application of Interface Segregation Principle and Cohesive Design.

Example implementation

Above code provides an infrastructure code inside SAP Commerce to support SSE. Below is provided example on how to use that infrastructure code for real business case. Let’s imagine that we want to notify FE that underlying ERP service is not currently available to show user on all pages a message that some functionality is not available right now, or that prices are not final etc.

BE implementation

First of all we need to create implementation of SseCompatibleSpringEvent interface for triggering spring evens and ErpStatusEventData to hold data about current state of ERP.

SseCompatibleSpringEvent implementation
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.blog.core.event;

import com.blog.core.data.ErpStatusEventData;
import com.blog.core.sse.SseCompatibleSpringEvent;
import de.hybris.platform.servicelayer.event.events.AbstractEvent;

public class ErpStatusUpdateEvent extends AbstractEvent implements SseCompatibleSpringEvent {


    private final ErpStatusEventData erpStatusEventData;

    public ErpStatusUpdateEvent(boolean isOffline, long offlineStartTime) {
        this.erpStatusEventData = new ErpStatusEventData(isOffline, offlineStartTime);
    }

    @Override
    public Object getSseData() {
        return erpStatusEventData;
    }
}
1
2
3
4
5
6
7
package com.blog.core.data;

import java.io.Serializable;

public record ErpStatusEventData(boolean isOffline, long offlineStartTime) implements Serializable {

}
SseEvent converter and SseEmitterInitEventConverter

Next step would be to create converter from ErpStatusUpdateEvent to SseEvent, which would also implement a SseEmitterInitEventConverter, so on SSE connection establishing FE would receive current state of ERP. Event data is converted into JSON, so it is easier for FE consumption.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package com.blog.async.sse.converter.impl;

import com.blog.async.sse.converter.SseEmitterInitEventConverter;
import com.blog.async.sse.domain.SimpleSseEvent;
import com.blog.async.sse.domain.SseEvent;
import com.blog.core.data.ErpStatusEventData;
import com.blog.core.erp.ErpStatusService;
import com.blog.core.event.ErpStatusUpdateEvent;
import com.blog.core.json.BlogJsonService;
import de.hybris.platform.core.model.user.UserModel;
import de.hybris.platform.servicelayer.user.UserService;
import org.springframework.core.convert.converter.Converter;

import javax.annotation.Nullable;
import javax.annotation.Resource;
import javax.validation.constraints.NotNull;

public class ErpStatusUpdateEventConverter implements Converter<ErpStatusUpdateEvent, SseEvent>, SseEmitterInitEventConverter {

    private enum ErpStatusEventType {
        ONLINE, OFFLINE
    }

    @Resource
    private ErpStatusService erpStatusService;

    @Resource
    private UserService userService;

    @Resource
    private BlogJsonService blogJsonService;

    private record ErpStatusUpdateEventData(String status, long offlineStartTime) {

    }

    @Nullable
    @Override
    public SseEvent convert(@NotNull ErpStatusUpdateEvent source) {
        return internalConvert(source);
    }

    @Override
    public SseEvent convert(@Nullable UserModel user, String emitterId) {
        if (userService.isAnonymousUser(user)) {
            return null;
        }
        return internalConvert(null);
    }

    private SimpleSseEvent internalConvert(ErpStatusUpdateEvent source) {
        return new SimpleSseEvent(
                "calculation_services_state_update",
                blogJsonService.toJson(resolveEventData(source))
        );
    }

    private ErpStatusUpdateEventData resolveEventData(ErpStatusUpdateEvent springEvent) {
        if (springEvent == null) {
            return resolveInitialEventData();
        }

        return resoleEventDataFromSpringEvent(springEvent);
    }

    private static ErpStatusUpdateEventData resoleEventDataFromSpringEvent(ErpStatusUpdateEvent springEvent) {
        Object sseData = springEvent.getSseData();
        if (sseData instanceof ErpStatusEventData erpStatusEventData) {
            return createEventData(
                    getErpStatusEventType(erpStatusEventData.isOffline())
                            .name(),
                    erpStatusEventData.offlineStartTime()
            );
        }
        return null;
    }

    private ErpStatusUpdateEventData resolveInitialEventData() {
        return createEventData(
                getErpStatusEventType(erpStatusService.isOffline())
                        .name(),
                erpStatusService.offlineStartTime()
        );
    }

    private static ErpStatusUpdateEventData createEventData(String status, long offlineStartTime) {
        return new ErpStatusUpdateEventData(
                status,
                offlineStartTime
        );
    }

    private static ErpStatusEventType getErpStatusEventType(boolean isOffline) {
        return isOffline ? ErpStatusEventType.OFFLINE : ErpStatusEventType.ONLINE;
    }

}
1
2
3
4
5

<bean id="erpStatusUpdateEventConverter" class="com.blog.async.sse.converter.impl.ErpStatusUpdateEventConverter"/>
<util:map id="sseCompatibleSpringEventConverters" key-type="java.lang.Class">
<entry key="com.blog.core.event.ErpStatusUpdateEvent" value-ref="erpStatusUpdateEventConverter"/>
</util:map>
Publish SseCompatibleSpringEvent

Now we can simply publish regular Spring Event with de.hybris.platform.servicelayer.event.EventService and receive that information on FE:

1
eventService.publishEvent(new ErpStatusUpdateEvent(isOffline, offlineStartTime));
1
calculation_services_state_update: {"status": "ONLINE", "offlineStartTime" : "1742635632" }

CORS headers configuration

Add corsFilter into asyncwebservices-web-spring.xml. It has same setup as OCC endpoints, reusing OCC properties:

1
2
3
corsfilter.asyncwebservices.allowedOrigins = ${corsfilter.commercewebservices.allowedOrigins}
corsfilter.asyncwebservices.allowedMethods = ${corsfilter.commercewebservices.allowedMethods}
corsfilter.asyncwebservices.allowedHeaders = ${corsfilter.commercewebservices.allowedHeaders}

FE implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
let unsuccessfulConnectCount = 0;
const reconnectIntervals = [1, 2, 5, 10, 20];
let eventSource;

const createOrReconnectEventSource = (url) => {
    const connect = () => {
        eventSource = new EventSource(url);

        eventSource.onopen = () => {
            unsuccessfulConnectCount = 0;
        };

        eventSource.onerror = () => {
            unsuccessfulConnectCount++;
            if (reconnectIntervals[unsuccessfulConnectCount]) {
                setTimeout(connect, reconnectIntervals[unsuccessfulConnectCount] * 1000);
            } else {
                console.error("Connection failed after multiple attempts.");
            }
        };
    };

    if (eventSource && eventSource.readyState === EventSource.OPEN) {
        return eventSource;
    }

    if (eventSource) {
        eventSource.close();
    }

    connect();
    return eventSource;
};

const addEventSubscription = (eventName, callback) => {
    if (eventSource) {
        eventSource.addEventListener(eventName, callback);
    } else {
        console.error("EventSource not created");
    }
};

const url = `/asyncwebservices/sse?access_token=${getCookie("access_token")}`;
const eventSourceInstance = createOrReconnectEventSource(url);

addEventSubscription("custom_event", (e) => console.log(e.data));
addEventSubscription("another_event", (e) => console.log(e.data));

Above implementation uses browser default EventSource to work with SSE.

Appendix

SseClientIdValidator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.blog.async.validator;

import de.hybris.platform.servicelayer.user.UserService;
import org.apache.commons.lang.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

import javax.annotation.Resource;

public class SseClientIdValidator implements Validator {

    @Resource
    private UserService userService;

    @Override
    public boolean supports(Class<?> clazz) {
        return String.class.equals(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        String clientId = (String) target;
        if (userService.isAnonymousUser(userService.getCurrentUser())) {
            if (StringUtils.isBlank(clientId)) {
                errors.reject("Anonymous users must provide Client ID for SSE.");
            }
        } else {
            if (StringUtils.isNotEmpty(clientId)) {
                errors.reject("Authenticated users must not provide Client ID for SSE.");
            }
        }

    }

}

SseEvent and SimpleSseEvent

1
2
3
4
5
6
7
8
9
package com.blog.async.sse.domain;

public interface SseEvent {

    String name();

    String data();

}
1
2
3
4
5
package com.blog.async.sse.domain;

public record SimpleSseEvent(String name, String data) implements SseEvent {

}

Production CDNs

Some of CDN providers, like Akamai, can buffer/batch server responses, while using SSE. And it is required to work with their support to enable realtime SSE on production. Here is a link on stackoverflow with changes required to be done on Akamai side.

IllegalStateException: Async support must be enabled on a servlet

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
[2025/03/16 13:19:23.738] ERROR [hybrisHTTP39] [] [AbstractRestHandlerExceptionResolver] java.lang.IllegalStateException: Async support must be enabled on a servlet and for all filters involved in async request processing. This is done in Java code using the Servlet API or by adding "<async-supported>true</async-supported>" to servlet and filter declarations in web.xml.
	at org.springframework.util.Assert.state(Assert.java:76)
	at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.startAsync(StandardServletAsyncWebRequest.java:141)
	at org.springframework.web.context.request.async.WebAsyncManager.startAsyncProcessing(WebAsyncManager.java:517)
	at org.springframework.web.context.request.async.WebAsyncManager.startDeferredResultProcessing(WebAsyncManager.java:492)
	at org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler.handleReturnValue(ResponseBodyEmitterReturnValueHandler.java:177)
	at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78)
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:135)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:903)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:809)
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072)
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:529)
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:623)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:209)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:339)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$StatisticsGatewayFilter.doFilter(AbstractPlatformFilterChain.java:427)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:352)
	at de.hybris.platform.webservicescommons.oauth2.HybrisOauth2UserFilter.doFilter(HybrisOauth2UserFilter.java:47)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:117)
	at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:83)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126)
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:131)
	at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:85)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:164)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationProcessingFilter.doFilter(OAuth2AuthenticationProcessingFilter.java:182)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90)
	at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:117)
	at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:87)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.access.channel.ChannelProcessingFilter.doFilter(ChannelProcessingFilter.java:133)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:361)
	at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:225)
	at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:190)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
	at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
	at de.hybris.platform.webservicescommons.filter.RestSessionFilter.doFilter(RestSessionFilter.java:39)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
	at de.hybris.platform.servicelayer.web.TenantActivationFilter.doFilter(TenantActivationFilter.java:76)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
	at de.hybris.platform.servicelayer.web.Log4JFilter.doFilter(Log4JFilter.java:37)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain$InternalFilterChain.doFilter(AbstractPlatformFilterChain.java:309)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain.processStandardFilterChain(AbstractPlatformFilterChain.java:217)
	at de.hybris.platform.servicelayer.web.AbstractPlatformFilterChain.doFilterInternal(AbstractPlatformFilterChain.java:194)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
	at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:354)
	at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:267)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
	at de.hybris.platform.webservicescommons.filter.SessionHidingFilter.doFilter(SessionHidingFilter.java:34)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
	at de.hybris.platform.servicelayer.web.XSSFilter.processPatternsAndDoFilter(XSSFilter.java:351)
	at de.hybris.platform.servicelayer.web.XSSFilter.doFilter(XSSFilter.java:299)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:168)
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:481)
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:130)
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
	at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:670)
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:346)
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:390)
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:928)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1794)
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
	at java.base/java.lang.Thread.run(Thread.java:840)
comments powered by Disqus