001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.time.Duration; 025import java.util.ArrayList; 026import java.util.List; 027import java.util.Optional; 028import java.util.stream.Collectors; 029 030import static java.lang.String.format; 031import static java.util.Objects.requireNonNull; 032 033/** 034 * Encapsulates a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events">Server-Sent Event</a> payload that can be sent across the wire to a client. 035 * <p> 036 * For example: 037 * <pre>{@code ServerSentEvent event = ServerSentEvent.withEvent("example") 038 * .data(""" 039 * { 040 * "testing": 123, 041 * "value": "abc" 042 * } 043 * """) 044 * .id(UUID.randomUUID().toString()) 045 * .retry(Duration.ofSeconds(5)) 046 * .build();}</pre> 047 * <p> 048 * Threadsafe instances can be acquired via these builder factory methods: 049 * <ul> 050 * <li>{@link #withEvent(String)} (builder primed with an event value)</li> 051 * <li>{@link #withData(String)} (builder primed with a data value)</li> 052 * <li>{@link #builder()} ("empty" builder suitable for constructing special cases like {@code retry}-only or {@code id}-only events.)</li> 053 * </ul> 054 * <p> 055 * See <a href="https://www.soklet.com/docs/server-sent-events">https://www.soklet.com/docs/server-sent-events</a> for detailed documentation. 056 * <p> 057 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 058 * 059 * @author <a href="https://www.revetkn.com">Mark Allen</a> 060 */ 061@ThreadSafe 062public final class ServerSentEvent { 063 @Nullable 064 private final String id; 065 @Nullable 066 private final String event; 067 @Nullable 068 private final String data; 069 @Nullable 070 private final Duration retry; 071 072 /** 073 * Acquires a builder for {@link ServerSentEvent} instances, seeded with an {@code event} value. 074 * 075 * @param event the {@code event} value for the instance 076 * @return the builder 077 */ 078 @NonNull 079 public static Builder withEvent(@Nullable String event) { 080 return new Builder().event(event); 081 } 082 083 /** 084 * Acquires a builder for {@link ServerSentEvent} instances, seeded with a {@code data} value. 085 * 086 * @param data the {@code data} value for the instance 087 * @return the builder 088 */ 089 @NonNull 090 public static Builder withData(@Nullable String data) { 091 return new Builder().data(data); 092 } 093 094 /** 095 * Acquires an "empty" builder for {@link ServerSentEvent} instances, useful for creating special cases like {@code retry}-only or {@code id}-only events. 096 * 097 * @return the builder 098 */ 099 @NonNull 100 public static Builder builder() { 101 return new Builder(); 102 } 103 104 protected ServerSentEvent(@NonNull Builder builder) { 105 requireNonNull(builder); 106 107 this.id = builder.id; 108 this.event = builder.event; 109 this.data = builder.data; 110 this.retry = builder.retry; 111 112 // Ensure legal construction 113 114 if (this.retry != null && this.retry.isNegative()) 115 throw new IllegalArgumentException(format("%s 'retry' values must be non-negative. You supplied '%s'", 116 ServerSentEvent.class.getSimpleName(), this.retry)); 117 118 if (this.event != null && containsLineBreaks(this.event)) 119 throw new IllegalArgumentException(format("%s 'event' values must not contain CR or LF characters. You supplied '%s'", 120 ServerSentEvent.class.getSimpleName(), Utilities.printableString(this.event))); 121 122 if (this.id != null && (containsLineBreaks(this.id) || this.id.contains("\u0000"))) 123 throw new IllegalArgumentException(format("%s 'id' values must not contain NUL (\\u0000), CR, or LF characters. You supplied '%s'", 124 ServerSentEvent.class.getSimpleName(), Utilities.printableString(this.id))); 125 } 126 127 @NonNull 128 private Boolean containsLineBreaks(@NonNull String string) { 129 requireNonNull(string); 130 return string.indexOf('\n') >= 0 || string.indexOf('\r') >= 0; 131 } 132 133 /** 134 * Builder used to construct instances of {@link ServerSentEvent} via {@link ServerSentEvent#withEvent(String)}, {@link ServerSentEvent#withData(String)}, or {@link ServerSentEvent#builder()}. 135 * <p> 136 * This class is intended for use by a single thread. 137 * 138 * @author <a href="https://www.revetkn.com">Mark Allen</a> 139 */ 140 @NotThreadSafe 141 public static final class Builder { 142 @Nullable 143 private String id; 144 @Nullable 145 private String event; 146 @Nullable 147 private String data; 148 @Nullable 149 private Duration retry; 150 151 protected Builder() { 152 // Nothing to do 153 } 154 155 @NonNull 156 public Builder id(@Nullable String id) { 157 this.id = id; 158 return this; 159 } 160 161 @NonNull 162 public Builder event(@Nullable String event) { 163 this.event = event; 164 return this; 165 } 166 167 @NonNull 168 public Builder data(@Nullable String data) { 169 this.data = data; 170 return this; 171 } 172 173 @NonNull 174 public Builder retry(@Nullable Duration retry) { 175 this.retry = retry; 176 return this; 177 } 178 179 @NonNull 180 public ServerSentEvent build() { 181 return new ServerSentEvent(this); 182 } 183 } 184 185 @Override 186 @NonNull 187 public String toString() { 188 List<String> components = new ArrayList<>(4); 189 190 if (this.event != null) 191 components.add(format("event=%s", this.event)); 192 if (this.id != null) 193 components.add(format("id=%s", this.id)); 194 if (this.retry != null) 195 components.add(format("retry=%s", this.retry)); 196 if (this.data != null) 197 components.add(format("data=%s", this.data.trim())); 198 199 return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", "))); 200 } 201 202 /** 203 * The {@code id} for this Server-Sent Event, used by clients to populate the {@code Last-Event-ID} request header should a reconnect occur. 204 * <p> 205 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 206 * 207 * @return the optional {@code id} for this Server-Sent Event 208 */ 209 @NonNull 210 public Optional<String> getId() { 211 return Optional.ofNullable(this.id); 212 } 213 214 /** 215 * The {@code event} value for this Server-Sent Event. 216 * <p> 217 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 218 * 219 * @return the optional {@code event} value for this Server-Sent Event 220 */ 221 @NonNull 222 public Optional<String> getEvent() { 223 return Optional.ofNullable(this.event); 224 } 225 226 /** 227 * The {@code data} payload for this Server-Sent Event. 228 * <p> 229 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 230 * 231 * @return the optional {@code data} payload for this Server-Sent Event 232 */ 233 @NonNull 234 public Optional<String> getData() { 235 return Optional.ofNullable(this.data); 236 } 237 238 /** 239 * The {@code retry} duration for this Server-Sent Event. 240 * <p> 241 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 242 * 243 * @return the optional {@code retry} duration for this Server-Sent Event 244 */ 245 @NonNull 246 public Optional<Duration> getRetry() { 247 return Optional.ofNullable(this.retry); 248 } 249}