Skip to content

feat(client): add Hrana 3 JSON over WebSocket transport - #190

Merged
magnusp merged 5 commits into
mainfrom
feat/i187-hrana-ws-json
Oct 24, 2026
Merged

magnusp merged 5 commits into
mainfrom
feat/i187-hrana-ws-json

Conversation

@magnusp

@magnusp magnusp commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Add a WebSocket transport that speaks Hrana 3 JSON (hrana3 subprotocol) to sqld. A JDBC URL of jdbc:libsql:ws://host:port or jdbc:libsql:wss://host:port now uses it. The HTTP transport is unchanged.

Fixes #187.

What changes

  • libsql-hrana-codec gains the WebSocket envelope models (WsClientMsg, WsServerMsg, WsRequest, WsResponse) and the HranaWsCodec SPI. DirectHranaJsonCodec implements the JSON envelope, reusing its existing leaf serializers.
  • libsql-client gains a LibsqlClient seam. LibsqlHttpClient implements it, and LibsqlWebSocketClient is the new transport. LibsqlConnection now depends on the seam instead of the HTTP client and the baton.
  • libsql-jdbc accepts ws and wss URLs and selects the client from the scheme.

Why a seam is required

LibsqlConnection used the HTTP baton as the single source of truth for whether a stream is open. WebSocket has no baton, because the client assigns a stream_id. The client now owns the stream state and exposes hasOpenStream(), so the lazy transaction begin keeps working on both transports.

WebSocket implementation note

The client speaks RFC 6455 directly over a socket. java.net.http.WebSocket is not used because the JDK opening handshake waits for a response body that sqld never sends. A JDK probe reproduces the hang against the pinned image, while a raw WebSocket client completes the same handshake.

Evidence

  • ./mvnw -q -pl libsql-hrana-codec,libsql-client,libsql-jdbc -am test passes.
  • ./mvnw -q spotless:check passes.
  • DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" ./mvnw -pl libsql-itests -am -Dit.test=WebSocketTransportIT -DfailIfNoTests=false verify passes 5 of 5 against ghcr.io/tursodatabase/libsql-server:v0.24.33.
  • The full libsql-itests verify suite passed before the field-order fix. That fix only changes the WebSocket codec.

The integration test covers typed select values, transaction commit and rollback on one stream, error mapping, metadata, batch execution, and URL parsing.

Limits

Add the WebSocket envelope on top of the existing leaf structures:
sealed WsClientMsg/WsRequest/WsServerMsg/WsResponse records and an
HranaWsCodec SPI implemented by DirectHranaJsonCodec. Wire format
follows the Hrana 3 specification text frames, including null JWTs
and request_id/response type matching.
Introduce LibsqlClient as the transport-independent client used by the
JDBC driver, move the HTTP interactive baton state into LibsqlHttpClient,
and add LibsqlWebSocketClient speaking Hrana 3 JSON with the hrana3
subprotocol.

The WebSocket is implemented over Socket/SSLSocket rather than
java.net.http.WebSocket: sqld answers the upgrade with a 101 carrying
content-length: 31 and no body, and the JDK handshake waits for a full
response body, so it never completes against any sqld version.
…ient

LibsqlUrlParser now keeps ws/wss schemes (including jdbc:sqlite: forms)
and maps tls=true to wss. LibsqlDriver picks LibsqlWebSocketClient for
those URLs. LibsqlConnection works only through the LibsqlClient seam,
so auto-commit paths use one-shot calls and transactions use the
interactive stream.
Run selects, commit/rollback, failing statements, batches and metadata
against jdbc:libsql:ws:// on the pinned sqld container, and check that
ws/wss URLs parse with the expected scheme and path.
@magnusp
magnusp merged commit 22c67d5 into main Oct 24, 2026
6 checks passed
@magnusp
magnusp deleted the feat/i187-hrana-ws-json branch October 24, 2026 01:58
magnusp added a commit that referenced this pull request Oct 24, 2026
Add the Protobuf encoding for Hrana 3 over WebSocket (`hrana3-protobuf`
subprotocol, binary frames). JSON stays the default. A URL of
`jdbc:libsql:ws://host:port?encoding=protobuf` selects it.

Fixes #188.

This PR is stacked on #190 and targets `feat/i187-hrana-ws-json`. Merge
#190 first.

## What changes

- `HranaWsProtobufCodec` implements `HranaWsCodec` over the already
vendored `hrana_ws.proto` and `hrana.proto` schemas. It maps `Stmt`,
`Value`, `Batch`, `BatchCond`, `StmtResult`, and `BatchResult` both
ways.
- `LibsqlClientConfig` gains an `Encoding` enum (`JSON`, `PROTOBUF`) as
its last component. The existing constructors still default to JSON.
- `LibsqlUrlParser` reads `encoding=json|protobuf|proto`. An unknown
value throws, and `encoding=protobuf` on an `http` or `https` URL throws
because Protobuf over HTTP is out of scope.
- `LibsqlWebSocketClient` derives the subprotocol from the codec,
`hrana3` for JSON and `hrana3-protobuf` for Protobuf, and rejects a
server that negotiates a different one.
- `LibsqlDriver` selects the codec from the encoding.

No new dependency. `protobuf-java` and the codegen were already present.

## Evidence

- `./mvnw -q -pl libsql-hrana-codec,libsql-client,libsql-jdbc -am test`
passes.
- `./mvnw -q spotless:check` passes.
- `DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" ./mvnw
-pl libsql-itests -am -Dit.test='WebSocket*TransportIT'
-DfailIfNoTests=false verify` passes 8 of 8, split as
`WebSocketTransportIT` 5 of 5 on JSON and `WebSocketProtobufTransportIT`
3 of 3 on Protobuf.

The Protobuf integration test covers typed select values including
`Long.MIN_VALUE` and a blob, transaction commit and rollback, and error
mapping. The codec unit test covers `Long.MIN_VALUE`, `Long.MAX_VALUE`,
doubles, unicode text, null, empty and non-empty blobs, named arguments,
batch conditions, and both error messages.

## Limits

- Protobuf over HTTP (`v3-protobuf/pipeline`) is out of scope.
- `wss` has no TLS integration test. The container runs plain `ws`.
- Cursor requests are not implemented. Nothing in the driver uses them.
magnusp added a commit that referenced this pull request Oct 24, 2026
…d-rolled framer (#193)

Replace the hand-rolled RFC 6455 transport inside
`LibsqlWebSocketClient` with Apache
HttpClient's `httpclient5-websocket` module. The WebSocket transport now
reuses a library
that already does framing, handshake, TLS, and fragmentation, and about
380 lines of that
code leave the client.

Fixes #192.

## Why

sqld answers the upgrade with a `101` that carries `content-length: 31`
and no body, so the
JDK client's `java.net.http.WebSocket` never finishes its opening
handshake. #190 shipped a
hand-written framer to work around that. HttpClient treats a `101` as
informational and
upgrades without reading a body, so the workaround is no longer needed.

## What changes

- `libsql-client` depends on `httpclient5-websocket` 5.7-beta1, pinned
in the root pom.
`LibsqlWebSocketClient` keeps its `request_id` matching, stream ids, and
blocking call
  semantics on top of the library.
- The `Transport` inner class, the static handshake, the frame encoder
and decoder, the
accept-key computation, and the socket and `SSLSocket` setup are
deleted.
- Closing a connection tears the client down in one step, so close
latency and thread
lifetime stay what the hand-rolled transport gave: single-digit
milliseconds and no
  leftover IO threads.
- Both WebSocket integration tests now run every case over `ws://` and
`wss://`. The wss
path goes through an nginx TLS terminator in front of sqld with a
self-signed localhost
  certificate that the failsafe JVM trusts.

## Scope

- JSON and Protobuf over `ws` and `wss`. No Hrana message, JDBC API, or
HTTP transport
  change.
- `permessage-deflate` is off, so the bytes on the wire stay what they
were.
- Cursor requests stay unimplemented, as in #190.

## Tradeoffs

- 5.7 is beta, so the version is pinned exactly and the adapter stays
inside one class.
- The dependency brings `httpclient5`, `httpcore5`, `httpcore5-h2`,
`httpclient5-cache`,
and `slf4j-api` into `libsql-client`. An application with no SLF4J
binding gets SLF4J's
  one-time "no providers" notice on stderr.
- The hand-rolled transport is deleted rather than kept behind a flag,
on your call.

## Blast Radius

Every `jdbc:libsql:ws://` and `wss://` connection changes transport. The
HTTP transport,
the codec, and the JDBC layer are untouched, and their suites still
pass. The hidden cost
is dependency footprint for anyone who only uses `http://` URLs but
resolves the client
module's transitive dependencies.

## Verification

- `./mvnw spotless:check` passes, and `./mvnw test` passes (374 JDBC
tests, 35 codec tests).
- `WebSocketTransportIT` and `WebSocketProtobufTransportIT` run 15 cases
across `ws://` and
`wss://` against `ghcr.io/tursodatabase/libsql-server:v0.24.33` and
pass.
- `mvn clean verify` passes locally, including the HTTP integration
tests, the Hikari pool
  churn test, the toxiproxy test, and the conformance suite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hrana WebSocket: support JSON over WebSocket (phase 1)

1 participant