diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a2c5450d..206a29f0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
push:
- branches: [ main ]
+ branches: [ main, develop ]
pull_request:
- branches: [ main ]
+ branches: [ main, develop ]
jobs:
build:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6c6a38c..09abeaa0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic
No unreleased changes yet.
+## [1.3.0] - 2026-01-25
+
+### Added
+- Configurable WebSocket buffer sizes for handling large Nostr events via `nostr.websocket.max-text-message-buffer-size` and `nostr.websocket.max-binary-message-buffer-size` properties.
+
+### Changed
+- No additional behavior changes in this release; Kind APIs and WebSocket concurrency improvements were introduced in 1.2.1.
+
+### Fixed
+- No new fixes beyond 1.2.1; this release focuses on configurable WebSocket buffer sizes.
## [1.2.1] - 2026-01-21
### Fixed
diff --git a/nostr-java-api/pom.xml b/nostr-java-api/pom.xml
index 12d640e3..be0f6ccc 100644
--- a/nostr-java-api/pom.xml
+++ b/nostr-java-api/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-base/pom.xml b/nostr-java-base/pom.xml
index 3749b8c6..885c9a1e 100644
--- a/nostr-java-base/pom.xml
+++ b/nostr-java-base/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml
index be230114..c877ad42 100644
--- a/nostr-java-client/pom.xml
+++ b/nostr-java-client/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java
index 5167fb36..beed430b 100644
--- a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java
+++ b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java
@@ -45,6 +45,10 @@ public class StandardWebSocketClient extends TextWebSocketHandler implements Web
private static final long DEFAULT_AWAIT_TIMEOUT_MS = 60000L;
/** Default max idle timeout for WebSocket sessions (1 hour). Set to 0 for no timeout. */
private static final long DEFAULT_MAX_IDLE_TIMEOUT_MS = 3600000L;
+ /** Default max text message buffer size (1MB). Large enough for NIP-60 wallet state events. */
+ private static final int DEFAULT_MAX_TEXT_MESSAGE_BUFFER_SIZE = 1048576;
+ /** Default max binary message buffer size (1MB). */
+ private static final int DEFAULT_MAX_BINARY_MESSAGE_BUFFER_SIZE = 1048576;
@Value("${nostr.websocket.await-timeout-ms:60000}")
private long awaitTimeoutMs;
@@ -329,21 +333,65 @@ public void close() throws IOException {
}
/**
- * Creates a Spring WebSocket client configured with an extended idle timeout.
+ * Creates a Spring WebSocket client configured with extended timeout and buffer sizes.
*
- *
The WebSocketContainer is configured with a max session idle timeout to prevent
- * premature connection closures. This is important for Nostr relays that may have
- * periods of inactivity between messages.
+ *
The WebSocketContainer is configured with:
+ *
+ * - Max session idle timeout to prevent premature connection closures (important for
+ * Nostr relays that may have periods of inactivity between messages)
+ * - Large text/binary message buffers (default 1MB) to handle NIP-60 wallet state events
+ * and other large Nostr events that can exceed default buffer sizes
+ *
+ *
+ * Configuration via system properties:
+ *
+ * - {@code nostr.websocket.max-idle-timeout-ms} - Max session idle timeout (default: 3600000)
+ * - {@code nostr.websocket.max-text-message-buffer-size} - Max text message buffer (default: 1048576)
+ * - {@code nostr.websocket.max-binary-message-buffer-size} - Max binary message buffer (default: 1048576)
+ *
*
* @return a configured Spring StandardWebSocketClient
*/
private static org.springframework.web.socket.client.standard.StandardWebSocketClient createSpringClient() {
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
- container.setDefaultMaxSessionIdleTimeout(DEFAULT_MAX_IDLE_TIMEOUT_MS);
- log.debug("websocket_container_configured max_idle_timeout_ms={}", DEFAULT_MAX_IDLE_TIMEOUT_MS);
+
+ long idleTimeout = getLongProperty("nostr.websocket.max-idle-timeout-ms", DEFAULT_MAX_IDLE_TIMEOUT_MS);
+ int textBufferSize = getIntProperty("nostr.websocket.max-text-message-buffer-size", DEFAULT_MAX_TEXT_MESSAGE_BUFFER_SIZE);
+ int binaryBufferSize = getIntProperty("nostr.websocket.max-binary-message-buffer-size", DEFAULT_MAX_BINARY_MESSAGE_BUFFER_SIZE);
+
+ container.setDefaultMaxSessionIdleTimeout(idleTimeout);
+ container.setDefaultMaxTextMessageBufferSize(textBufferSize);
+ container.setDefaultMaxBinaryMessageBufferSize(binaryBufferSize);
+
+ log.info("websocket_container_configured max_idle_timeout_ms={} max_text_buffer={} max_binary_buffer={}",
+ idleTimeout, textBufferSize, binaryBufferSize);
return new org.springframework.web.socket.client.standard.StandardWebSocketClient(container);
}
+ private static long getLongProperty(String key, long defaultValue) {
+ String value = System.getProperty(key);
+ if (value != null && !value.isEmpty()) {
+ try {
+ return Long.parseLong(value);
+ } catch (NumberFormatException e) {
+ log.warn("Invalid value for property {}: {}, using default: {}", key, value, defaultValue);
+ }
+ }
+ return defaultValue;
+ }
+
+ private static int getIntProperty(String key, int defaultValue) {
+ String value = System.getProperty(key);
+ if (value != null && !value.isEmpty()) {
+ try {
+ return Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ log.warn("Invalid value for property {}: {}, using default: {}", key, value, defaultValue);
+ }
+ }
+ return defaultValue;
+ }
+
private void dispatchMessage(String payload) {
listeners.values().forEach(listener -> safelyInvoke(listener.messageListener(), payload, listener));
}
diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/StandardWebSocketClientMultiMessageTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/StandardWebSocketClientMultiMessageTest.java
new file mode 100644
index 00000000..73c5236a
--- /dev/null
+++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/StandardWebSocketClientMultiMessageTest.java
@@ -0,0 +1,135 @@
+package nostr.client.springwebsocket;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.web.socket.TextMessage;
+import org.springframework.web.socket.WebSocketSession;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests that StandardWebSocketClient correctly accumulates multiple messages
+ * before completing when a termination message (EOSE, OK) is received.
+ */
+class StandardWebSocketClientMultiMessageTest {
+
+ @Test
+ void testAccumulatesMessagesUntilEose() throws Exception {
+ WebSocketSession session = Mockito.mock(WebSocketSession.class);
+ when(session.isOpen()).thenReturn(true);
+
+ StandardWebSocketClient client = new StandardWebSocketClient(session, 5000, 100);
+
+ // Simulate relay responses when send is called
+ CountDownLatch sendLatch = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ sendLatch.countDown();
+ return null;
+ }).when(session).sendMessage(any(TextMessage.class));
+
+ // Start send in background thread
+ Thread sendThread = new Thread(() -> {
+ try {
+ List result = client.send("[\"REQ\",\"sub1\",{}]");
+ assertEquals(2, result.size(), "Should receive EVENT + EOSE");
+ assertTrue(result.get(0).contains("EVENT"), "First message should be EVENT");
+ assertTrue(result.get(1).contains("EOSE"), "Second message should be EOSE");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ sendThread.start();
+
+ // Wait for send to start
+ assertTrue(sendLatch.await(1, TimeUnit.SECONDS), "Send should have started");
+ Thread.sleep(50); // Small delay for send() to set up pendingRequest
+
+ // Simulate EVENT message (not termination - should NOT complete)
+ client.handleTextMessage(session, new TextMessage("[\"EVENT\",\"sub1\",{\"id\":\"abc\"}]"));
+
+ // Small delay to ensure processing
+ Thread.sleep(50);
+
+ // Simulate EOSE message (termination - should complete)
+ client.handleTextMessage(session, new TextMessage("[\"EOSE\",\"sub1\"]"));
+
+ // Wait for send thread to complete
+ sendThread.join(2000);
+ }
+
+ @Test
+ void testCompletesImmediatelyOnOk() throws Exception {
+ WebSocketSession session = Mockito.mock(WebSocketSession.class);
+ when(session.isOpen()).thenReturn(true);
+
+ StandardWebSocketClient client = new StandardWebSocketClient(session, 5000, 100);
+
+ CountDownLatch sendLatch = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ sendLatch.countDown();
+ return null;
+ }).when(session).sendMessage(any(TextMessage.class));
+
+ Thread sendThread = new Thread(() -> {
+ try {
+ List result = client.send("[\"EVENT\",{\"id\":\"abc\"}]");
+ assertEquals(1, result.size(), "Should receive just OK");
+ assertTrue(result.get(0).contains("OK"), "Message should be OK");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ sendThread.start();
+
+ assertTrue(sendLatch.await(1, TimeUnit.SECONDS));
+ Thread.sleep(50);
+
+ // Simulate OK message (termination - should complete immediately)
+ client.handleTextMessage(session, new TextMessage("[\"OK\",\"abc\",true,\"\"]"));
+
+ sendThread.join(2000);
+ }
+
+ @Test
+ void testEventWithoutEoseTimesOut() throws Exception {
+ WebSocketSession session = Mockito.mock(WebSocketSession.class);
+ when(session.isOpen()).thenReturn(true);
+
+ // Short timeout for this test
+ StandardWebSocketClient client = new StandardWebSocketClient(session, 200, 50);
+
+ CountDownLatch sendLatch = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ sendLatch.countDown();
+ return null;
+ }).when(session).sendMessage(any(TextMessage.class));
+
+ Thread sendThread = new Thread(() -> {
+ try {
+ List result = client.send("[\"REQ\",\"sub1\",{}]");
+ // Without EOSE, should timeout and return empty
+ assertTrue(result.isEmpty(), "Should timeout and return empty list");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ sendThread.start();
+
+ assertTrue(sendLatch.await(1, TimeUnit.SECONDS));
+ Thread.sleep(50);
+
+ // Simulate EVENT message but no EOSE
+ client.handleTextMessage(session, new TextMessage("[\"EVENT\",\"sub1\",{\"id\":\"abc\"}]"));
+
+ // Don't send EOSE - should timeout
+ sendThread.join(1000);
+ }
+}
diff --git a/nostr-java-crypto/pom.xml b/nostr-java-crypto/pom.xml
index 43b73675..23b02b2f 100644
--- a/nostr-java-crypto/pom.xml
+++ b/nostr-java-crypto/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-encryption/pom.xml b/nostr-java-encryption/pom.xml
index d7c8c9f7..d960cd82 100644
--- a/nostr-java-encryption/pom.xml
+++ b/nostr-java-encryption/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml
index 11f1435a..129f8eb8 100644
--- a/nostr-java-event/pom.xml
+++ b/nostr-java-event/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-examples/pom.xml b/nostr-java-examples/pom.xml
index 571c8657..04649fb3 100644
--- a/nostr-java-examples/pom.xml
+++ b/nostr-java-examples/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-id/pom.xml b/nostr-java-id/pom.xml
index 100969d7..fe6e4796 100644
--- a/nostr-java-id/pom.xml
+++ b/nostr-java-id/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/nostr-java-util/pom.xml b/nostr-java-util/pom.xml
index 4efd630a..89daa33d 100644
--- a/nostr-java-util/pom.xml
+++ b/nostr-java-util/pom.xml
@@ -4,7 +4,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
../pom.xml
diff --git a/pom.xml b/pom.xml
index c5327905..7d740d2a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
xyz.tcheeric
nostr-java
- 1.2.1
+ 1.3.0
pom
nostr-java