Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [ main ]
branches: [ main, develop ]
pull_request:
branches: [ main ]
branches: [ main, develop ]

jobs:
build:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion nostr-java-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-base/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>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.
* <p>The WebSocketContainer is configured with:
* <ul>
* <li>Max session idle timeout to prevent premature connection closures (important for
* Nostr relays that may have periods of inactivity between messages)</li>
* <li>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</li>
* </ul>
*
* <p>Configuration via system properties:
* <ul>
* <li>{@code nostr.websocket.max-idle-timeout-ms} - Max session idle timeout (default: 3600000)</li>
* <li>{@code nostr.websocket.max-text-message-buffer-size} - Max text message buffer (default: 1048576)</li>
* <li>{@code nostr.websocket.max-binary-message-buffer-size} - Max binary message buffer (default: 1048576)</li>
* </ul>
*
* @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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String> 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);
}
}
2 changes: 1 addition & 1 deletion nostr-java-crypto/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-encryption/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-event/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-examples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-id/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-util/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>1.2.1</version>
<version>1.3.0</version>
<packaging>pom</packaging>

<name>nostr-java</name>
Expand Down
Loading