Repository navigation
Conversation
…h, count body bytes with a plain writer
…request as parsed
… serve.go, restore the upstream channel path and Concurrency semantics
10b3169 to
5b4c631
Compare
9ea28ad to
4ada2ae
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The direct bridge omits the server start time, and the nested tools module still builds against upstream.
2 open findings
What changed in this PR
Adds direct HTTP request dispatch and verbatim json.RawMessage response streaming while migrating the module to the Stellar fork.
Changes:
- Adds
Server.ServeRequestsandResponse.WriteTo. - Reworks POST bridging to bypass the in-memory client.
- Updates module paths and expands direct-dispatch tests.
| File | Description |
|---|---|
serve.go |
Implements direct dispatch and streaming responses. |
jhttp/bridge.go |
Routes POST requests directly to the server. |
jrpc2_test.go |
Tests direct dispatch and response writing. |
jhttp/jhttp_test.go |
Tests batching, contexts, and raw results. |
go.mod |
Renames the root module. |
README.md |
Updates fork documentation links. |
doc.go |
Updates package documentation link. |
server.go |
Updates channel import path. |
client.go |
Updates channel import path. |
regression_test.go |
Updates fork imports. |
internal_test.go |
Updates fork imports. |
examples_test.go |
Updates fork imports. |
code_test.go |
Updates fork import. |
bench_test.go |
Updates fork imports. |
jhttp/getter.go |
Updates fork imports. |
jhttp/getter_test.go |
Updates imports and expected error text. |
jhttp/example_test.go |
Updates fork imports. |
handler/positional.go |
Updates root-package import. |
handler/handler.go |
Updates root-package import. |
handler/handler_test.go |
Updates fork imports. |
handler/example_test.go |
Updates fork imports. |
channel/channel_test.go |
Updates fork import. |
channel/bench_test.go |
Updates fork import. |
server/loop.go |
Updates fork imports. |
server/loop_test.go |
Updates fork imports. |
server/local.go |
Updates fork imports. |
server/local_test.go |
Updates fork imports. |
server/example_test.go |
Updates fork imports. |
internal/testutil/testutil.go |
Updates root-package import. |
internal/testutil/testutil_test.go |
Updates utility import. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Client: opts.clientOptions(), | ||
| Server: opts.serverOptions(), | ||
| }), | ||
| srv: jrpc2.NewServer(mux, opts.serverOptions()), |
There was a problem hiding this comment.
Calling handlers directly and passing json.RawMessage through is the right fix, and your numbers show it. The part I'd question is the fork. jrpc2 already exports what a direct-dispatch HTTP handler needs: ParseRequests, ParsedRequest.ToRequest, the Assigner, and jrpc2.Error. stellar-rpc already owns the rest of its HTTP stack, so the handler could live there, on unmodified upstream jrpc2.
To check, I wrote one: about 270 lines in a single file. It parses with jrpc2.ParseRequests, calls each handler with pr.ToRequest(), passes a non-empty json.RawMessage through, and writes each result once with a computed Content-Length. Against upstream's jhttp.Bridge, status, headers and body bytes matched on 220,063 requests, on Go 1.26.8 and 1.27.1. That was 63 targeted cases (method and content-type variants, invalid UTF-8, a 400 KB ID, a 50,000-element batch) plus 220,000 random batches. Your differential harness would be a good independent check. It runs at the same speed as this PR (Go 1.26.8, M1 Max, one warm POST over loopback with the body discarded, median of 8 runs):
| upstream bridge | this PR | in-tree on upstream jrpc2 | |
|---|---|---|---|
| 3 MiB struct | 55.3 ms, 27.3 MiB | 4.00 ms, 5.9 MiB | 3.93 ms, 6.0 MiB |
3 MiB json.RawMessage |
63.1 ms, 24.7 MiB | 0.54 ms, 10 KiB | 0.57 ms, 11 KiB |
| small struct | 80 µs | 55 µs | 53 µs |
In-tree avoids what the fork costs:
- The module rename is why stellar/go-stellar-sdk#6020 exists, and that PR changes the error type SDK users match with
errors.As. - This branch is based on v1.3.5, and
serve.gono longer compiles against upstreammain, whereinboundRequestKeyandserverKeybecamemctxkeys. - Upstreaming would need a second execution path in
Server. When this came up in creachadair#99, the maintainer suggested plain HTTP handlers instead of the Bridge.
It also lets the handler own the global request deadline, which removes the duration limiter's copy of every response. I put the numbers in a comment on stellar/stellar-rpc#1037.
If that sounds right, this PR and stellar/go-stellar-sdk#6020 could close, and stellar/stellar-rpc#1037 would swap jhttp.NewBridge for the in-tree handler. Either way, three things the old bridge did need to carry over. I left them as separate comments: the shutdown drain and the concurrency cap on stellar/stellar-rpc#1037, and the ID escaping inline here. The prototype is in this gist, if it's useful as a starting point.
| } | ||
| head := []byte(`{"jsonrpc":"2.0"`) | ||
| if r.id != "" { | ||
| head = append(append(head, `,"id":`...), r.id...) |
There was a problem hiding this comment.
Could the echoed ID keep its HTML escaping? It's the one client-supplied value written back into the response. The old bridge escaped it as a side effect of re-encoding, so today "id":"<b>" comes back as "\u003cb\u003e". Keeping it costs a scan of the ID and keeps responses byte-identical to the old bridge:
func escapeID(id string) string {
if !strings.ContainsAny(id, "<>&\u2028\u2029") {
return id
}
var buf bytes.Buffer
json.HTMLEscape(&buf, []byte(id))
return buf.String()
}In the bridge that's rsp.SetID(escapeID(rsp.ID())) before encoding. An in-tree handler would call it where it writes the ID.
|
Upstreaming the fix is harder than it looks. jrpc2's
Those are design calls for the maintainer. When per-request contexts in the Bridge came up in creachadair#99, he suggested plain HTTP handlers instead. |

Note: This branch is superseded by stellar-rpc/pull/1037, which does the work of this branch in-tree.
What
jhttp.Bridgeserves POST requests without its in-memory client round trip, and a handler'sjson.RawMessageresult goes to the socket verbatim. Based on upstream's latest release, v1.3.5 (go 1.25.0).Used by stellar/stellar-rpc#1037 and #1060, which keep calling
jhttp.NewBridge. stellar/go-stellar-sdk#6020 uses only the client, which is unchanged.Changes
serve.go(new). This is the only change to the root package apart from the module path.Server.ServeRequests(ctx, []*ParsedRequest) []*Responseserves parsed requests on an unstarted server, in request order. Handlers run onctxunder the concurrency limit.json.RawMessageresult is passed through as-is: no re-encoding, validation, or HTML escaping.Response.WriteTowrites the response without copying the result.jhttp.Bridge. POST requests go throughServeRequestson the HTTP request context and are streamed withWriteTo. Content-Length comes from a dry run into a counting writer.server.Local, ID virtualization, andmarshalErrorare removed.jhttp.Getter,tools/. Unchanged from v1.3.5 apart from import paths.Behavior vs. the old bridge (POST)
ServerOptions.NewContextis not used. A handler with side effects that must finish anyway should detach withcontext.WithoutCancel.CancelRequesthas no effect, and bridge traffic is not counted inbytes_read/bytes_written.<,>,&, U+2028 and U+2029 are no longer HTML-escaped. The JSON value is the same.jhttpsuite, which is unchanged apart from upstream's Go 1.27 test fix, and by a differential harness against the upstream bridge (218 cases plus ~490k random batches).Why
The old bridge re-encoded every result on its way to the socket: it marshaled in
invoke, encoded onto an in-memory pipe, parsed twice in the client, and compacted twice.getLatestLedgerandgetLedgersresults exceed 3 MB, andgetLatestLedgerserves a pre-rendered body.The table compares upstream v1.3.5 with this PR for one POST over loopback HTTP, with the response body discarded. Allocation counts server and client together. Figures are the median of 8 runs on an M4 Pro with Go 1.26.
json.RawMessage🤖 Generated with Claude Code