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: + *

+ * + *

Configuration via system properties: + *

* * @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