From 4866ce40844935839ba5c52ce73f276f6fb42251 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 25 Jan 2026 17:59:29 +0000 Subject: [PATCH 01/10] feat(websocket): add configurable buffer sizes for large Nostr events Configure WebSocketContainer with larger text/binary message buffer sizes (default 1MB each) to handle NIP-60 wallet state events and other large Nostr events that exceed default buffer sizes. Buffer sizes are now configurable via system properties: - nostr.websocket.max-idle-timeout-ms (default: 3600000) - nostr.websocket.max-text-message-buffer-size (default: 1048576) - nostr.websocket.max-binary-message-buffer-size (default: 1048576) This fixes 1009 "message too big" errors that caused WebSocket disconnections when receiving large events from relays. Co-Authored-By: Claude Opus 4.5 --- .../StandardWebSocketClient.java | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) 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)); } From 916dd04c462ea37cbcabfbeb6e0fe534a37c3214 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 25 Jan 2026 18:09:24 +0000 Subject: [PATCH 02/10] =?UTF-8?q?chore(release):=20bump=20version=201.2.1?= =?UTF-8?q?=20=E2=86=92=201.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects updated: - nostr-java: 1.2.1 → 1.3.0 (minor) - All modules updated to match parent version Changes include: - Added configurable WebSocket buffer sizes for large Nostr events - Improved Kind enum with safer lookup methods - Fixed thread-safety in WebSocket client send() - Fixed fail-fast deserialization in KindFilter Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 14 ++++++++++++++ nostr-java-api/pom.xml | 2 +- nostr-java-base/pom.xml | 2 +- nostr-java-client/pom.xml | 2 +- nostr-java-crypto/pom.xml | 2 +- nostr-java-encryption/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-examples/pom.xml | 2 +- nostr-java-id/pom.xml | 2 +- nostr-java-util/pom.xml | 2 +- pom.xml | 2 +- 11 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c6a38c..d3c31c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ 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.text-buffer-size` and `nostr.websocket.binary-buffer-size` properties. + +### Changed +- Kind.valueOf(int) now returns null for unknown kind values instead of throwing, allowing graceful handling of custom or future NIP kinds during JSON deserialization. +- Added Kind.valueOfStrict(int) for callers who need fail-fast behavior on unknown kinds. +- Added Kind.findByValue(int) returning Optional for safe, explicit handling of unknown kinds. + +### Fixed +- WebSocket client now prevents concurrent send() calls with proper thread-safety using PendingRequest encapsulation. +- KindFilter and ClassifiedListingEventDeserializer now use Kind.valueOfStrict() for fail-fast deserialization of unknown kind values. + ## [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-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 From aed3adfaff14e498b2b6169d8fe7eee04c53b69e Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 25 Jan 2026 20:58:19 +0000 Subject: [PATCH 03/10] test(ws): add tests for message accumulation and timeout behavior in StandardWebSocketClient --- ...andardWebSocketClientMultiMessageTest.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 nostr-java-client/src/test/java/nostr/client/springwebsocket/StandardWebSocketClientMultiMessageTest.java 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); + } +} From 58153ed86af08f0a0b47cd63936159e8decf3922 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:21:35 +0000 Subject: [PATCH 04/10] Initial plan From e68ec398ad0ca54b8813f269fce58b86bb2a1a5e Mon Sep 17 00:00:00 2001 From: Eric T Date: Sun, 25 Jan 2026 21:21:47 +0000 Subject: [PATCH 05/10] chores: Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c31c67..1ee12d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ No unreleased changes yet. ## [1.3.0] - 2026-01-25 ### Added -- Configurable WebSocket buffer sizes for handling large Nostr events via `nostr.websocket.text-buffer-size` and `nostr.websocket.binary-buffer-size` properties. +- 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 - Kind.valueOf(int) now returns null for unknown kind values instead of throwing, allowing graceful handling of custom or future NIP kinds during JSON deserialization. From 1bf8954c4a6cd02834cfefefc22265773701cadc Mon Sep 17 00:00:00 2001 From: Eric T Date: Sun, 25 Jan 2026 21:22:15 +0000 Subject: [PATCH 06/10] fix: Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee12d08..09abeaa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,10 @@ No unreleased changes yet. - 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 -- Kind.valueOf(int) now returns null for unknown kind values instead of throwing, allowing graceful handling of custom or future NIP kinds during JSON deserialization. -- Added Kind.valueOfStrict(int) for callers who need fail-fast behavior on unknown kinds. -- Added Kind.findByValue(int) returning Optional for safe, explicit handling of unknown kinds. +- No additional behavior changes in this release; Kind APIs and WebSocket concurrency improvements were introduced in 1.2.1. ### Fixed -- WebSocket client now prevents concurrent send() calls with proper thread-safety using PendingRequest encapsulation. -- KindFilter and ClassifiedListingEventDeserializer now use Kind.valueOfStrict() for fail-fast deserialization of unknown kind values. - +- No new fixes beyond 1.2.1; this release focuses on configurable WebSocket buffer sizes. ## [1.2.1] - 2026-01-21 ### Fixed From 1c42ad38089dcf227bc956d5ac7a9c15982b06b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:22:50 +0000 Subject: [PATCH 07/10] Initial plan From e97f4ecd36d6e71e08778a626d6c8d464efba6f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:23:04 +0000 Subject: [PATCH 08/10] Initial plan From b290c3a74d609436f45b0400b5da14c54a487462 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:23:15 +0000 Subject: [PATCH 09/10] Initial plan From aa9a7ef0878ba615061a18da6f590760ee7e86fa Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 25 Jan 2026 21:28:18 +0000 Subject: [PATCH 10/10] fix(ci): allow CI to run on develop branch in addition to main --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: