refactor(server)!: XRPC endpoint hosting — route groups, one NSID source, correct binding #153
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!153
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-121-xrpc-hosting"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #121.
Server-side XRPC hosting (
ATProtoNet.Server/Xrpc): route groups, one NSID source, binding by property type, one error path.What changed
MapXrpcEndpoints()returns the/xrpcroute groupRouteGroupBuilder(fromMapGroup("/xrpc")), so.RequireAuthorization(),.RequireRateLimiting(…),.RequireCors(…)and.WithMetadata(…)apply to every XRPC endpoint.GetCustomAttributes(inherit: true)). That makes[Authorize],[AllowAnonymous],[EnableRateLimiting]and[RequestSizeLimit]work per handler, and[AllowAnonymous]exempts a handler from a group-wideRequireAuthorization().RequestDelegateendpoints (MapMethods(pattern, [method], RequestDelegate)), so no minimal-API delegate binding runs.One source of truth for the NSID
IXrpcEndpointis nowstatic abstract Nsid Nsid { get; }(typedATProtoNet.Identity.Nsid). Registration readsTHandler.Nsiddirectly. TheRuntimeHelpers.GetUninitializedObjectlookup is gone, along with its failure on auto-property and field initializers (regression test:AddXrpcEndpoint_AutoPropertyNsid_IsRead).[XrpcEndpoint]is removed.AddXrpcEndpointsFromAssemblyregisters every non-abstract, non-generic class implementingIXrpcEndpointby invokingAddXrpcEndpoint<T>itself, so the two paths are the same code. A test scans the Server assembly and checks that the result equals whatAddSpaceAuthority+AddSimpleSpace+AddSpaceRepoHostregister (19 endpoints, same NSID, method and handler).TryAddScoped).Query binding by target property type (X1, F25)
XrpcQueryBinder<TParams>. It reads the serializer's own contract once per type (wire names, property types,required, property-level converters). Per request it writes the query directly into a reused per-thread UTF-8 buffer as the JSON the contract expects, then deserializes from the span. There is noDictionary, no polymorphicobjectserialization and no intermediate JSON string.IReadOnlyList<T>, array) always gets an array:?uris=xbinds a one-element list. Repeated keys bind in order.uris[]=is accepted leniently (collections only). A repeated scalar is refused, to avoid parameter-pollution ambiguity.boolbinds withoutXrpcBooleanConverter. A property that carries a converter still gets the string, as before. Identifier types (Did,Nsid,AtUri, …) go through their ownIdentifierJsonConverter, and a missingrequiredparameter or any unbindable value answersInvalidRequestnaming the parameter. The serializer's message, which has CLR type names and byte positions, is no longer echoed.LexiconTypeRegistryunion variants, and an app may register those after its endpoints.One exception filter, typed error body
XrpcErrorResponse.Handlewraps every endpoint delegate:XrpcExceptionanswers with its status, name, message andHeaders.BadHttpRequestExceptionanswers with its status; 413 maps toPayloadTooLarge, anything else toInvalidRequest.500 {"error":"InternalServerError","message":"Internal Server Error"}and is logged ([LoggerMessage], categoryATProtoNet.Server.Xrpc) with the exception. Its message never reaches the client.XrpcErrorBody. Once the response has started (a blob failing halfway), the exception propagates so the server aborts the response rather than passing off a truncated body as complete.nulloutput is a 500, where it used to writenullwith a 200.Unmatched routes:
MethodNotImplementedfallbackThe
/xrpcgroup now holds a fallback,MapFallback("/{nsid}", …), that answers what no endpoint matched, following the referencexrpc-servercatch-all:error501MethodNotImplemented(messageMethod Not Implemented)405+AllowInvalidRequest(Incorrect HTTP method (POST), expected GET.)/xrpc/foo)400InvalidRequestAllowand an XRPC body. The status stays 405 as before; the error name isInvalidRequest, which is what the reference uses for this case (it answers with status 400).MapFallbackorders the fallback atint.MaxValue, after every other endpoint, and it matches only one segment below/xrpc. Registered endpoints and routes the app maps under/xrpcitself (e.g./xrpc/_health) still answer./other/…,/com.x.yand/xrpc/a/bremain plain 404s..RequireAuthorization()on the group, an anonymous caller probing an unknown NSID gets 401, not a map of which methods exist.invalid xrpc patherror; switch it to 501 if you prefer.Interface gaps
IXrpcProcedure<TOutput>: no input, JSON output.IXrpcBlobProcedure<TOutput>+XrpcBlobInput(Stream Content, string ContentType, long? ContentLength): an unbuffered request body, for blob-upload-style methods. A missingContent-TypeanswersInvalidRequest, as in the referencexrpc-server.IXrpcProcedureVoid(no input, no output). This goes beyond the issue text but fills the same "procedure with no input" gap fordeleteSession-shaped methods; see the flag below.IXrpcBlobProcedure/XrpcBlobInput, not the research'sIXrpcBinaryProcedure, to match the existingIXrpcBlobQuery/XrpcBlobResult.One generic registration path (debloat 6.1)
Kindenum, and theEnsureRegistry/GetOrCreateRegistrydouble registration are replaced by one table of shape → (HTTP method, generic factory). There is exactly oneMakeGenericMethodper registration, at startup. Requests run with no reflection.CopyToAsync, instead of throughResults.Stream.Spaces endpoints (debloat 6.2)
[XrpcEndpoint(Nsid = SpaceNsids.X)]and turnspublic string Nsid => SpaceNsids.X;intopublic static Nsid Nsid { get; } = Nsid.Parse(SpaceNsids.X);.NotifyWriteEndpointpassesSpaceNsids.NotifyWriteto the service-auth check (it used the instanceNsid).SpaceNsids("needed at attribute position") is removed. No parameter or model types changed.Breaking changes (CHANGELOG has migration notes)
[XrpcEndpoint(Nsid = …)]and replacepublic string Nsid => "…";withpublic static Nsid Nsid { get; } = Nsid.Parse("…");(using ATProtoNet.Identity;). The endpoint interfaces can no longer be generic type arguments (CS8920).AddXrpcEndpointsFromAssemblypicks up everyIXrpcEndpointclass, and invalid or conflicting handlers throw at registration.MapXrpcEndpoints()return type: it is nowRouteGroupBuilder.app.MapXrpcEndpoints();compiles unchanged. AMap…call chained onto the result would now map under/xrpc, so move it to the app.Verification
dotnet build(whole solution): 0 warnings, 0 errors.MethodNotImplementedfor GET and POST. POST to a query and GET to a procedure answer 405 withAllowand anInvalidRequestbody. A non-NSID segment answers 400./other/…,/com.x.yand/xrpc/a/bstay 404. App-mapped/xrpc/_healthand/xrpc/com.example.appOwnedare not shadowed. Under groupRequireAuthorization(), an unknown NSID answers 401 anonymous and 501 authenticated.SimpleSpaceEndpointTests.AddMember_IsNoLongerServednow expects 501MethodNotImplementedfor the removedaddMember(it expected 404).XrpcEndpointTests: every shape end to end; body errors (no body, wrong content type, malformed JSON, wrong field type); blob upload hashing 70 KB; blob download of 100 KB with length;XrpcExceptionstatus/headers; unknown exception → 500 with no secret in the body and one error log entry holding the exception; 413 →PayloadTooLarge; null output → 500.AddXrpcEndpoint<T>; Server-assembly scan ≡ SpacesAdd*registrations; open generics skipped;MapXrpcEndpointswith nothing registered throws.XrpcQueryBindingTests: single value → list/array; repeated keys;[]keys; mixed;Did/Nsid/AtUrilist binding and rejection; int/bool parse and rejection;List<bool>; repeated scalar refused; unknown keys ignored; JSON-escaping round trip; missingrequired; positional-record binding; 64 concurrent requests (thread-static buffer reuse).XrpcRouteGroupTests: groupRequireAuthorization()(401 → 200 with auth),[AllowAnonymous]overriding it, handler[Authorize]without a group convention, groupRequireRateLimiting(second call 429), and groupWithMetadatareaching the endpoint.SpaceServerEndpointTests,SimpleSpaceEndpointTests) pass unchanged through the new pipeline.Dictionary→ JSON string →Deserialize):listRecords, 6 scalars incl. a converter-bound boolDeferred / flagged
IXrpcProcedureVoid(non-generic) is extra public API beyond the issue checklist. It is small and fills the no-input/no-output gap; drop it if unwanted.importRepo), and subscriptions (IXrpcSubscription, research 13.5). Neither is in the issue checklist.OnRejected.XrpcBooleanConverterand the Spaces handler classes stay public; making them internal is #133 (13.7). Only the converter's remark was updated, since routing no longer needs it.GetInterfaces+MakeGenericMethodper handler, plus assembly scanning.Review follow-up
The first review (COMMENT) raised two findings. Both are fixed in the same commit.
XrpcEndpointRegistry.AddcomparedNsidrecords ordinally, while routing and the fallback's lookup are case-insensitive. Socom.example.simpleQueryandcom.example.SimpleQueryboth registered, andMapXrpcEndpoints()then crashed with an unhandledArgumentExceptionfrom the fallback's dictionary. The check is nowOrdinalIgnoreCaseand throws the usualInvalidOperationException, naming both handlers and both spellings. Test:AddXrpcEndpoint_NsidDifferingOnlyInCase_ThrowsLikeAnyConflict. The CHANGELOG bullet and the docs now say conflicts are compared case-insensitively.XrpcErrorResponse.Handlenow takes anyRequestDelegaterather than a registration. The unmatched-route fallback is wrapped exactly like the handlers, so an unexpected exception there answers the 500 envelope and is logged. Since the fallback has no single NSID, the log entry now names the request instead:XRPC request {Method} {Path} failed with an unhandled exception.Test:Handle_AnyDelegate_MapsItsExceptionToTheEnvelopeAndLogsIt.After the fix, the build has 0 warnings and 0 errors, and unit tests show 2886 passed, 0 failed, 5 skipped.
🤖 Generated with Claude Code
MapXrpcEndpoints() returned the builder it was called on, so no convention (RequireAuthorization, rate limiting, metadata) could be applied to XRPC endpoints. It now returns the /xrpc route group, and each endpoint carries its handler class's attributes as metadata, so [Authorize] and [AllowAnonymous] work per handler. The group's fallback answers /xrpc/{nsid} requests nothing matched the way the reference xrpc-server does: 501 MethodNotImplemented for an unknown NSID, and 405 with Allow and an InvalidRequest body for a known NSID called with the wrong method. An endpoint's NSID was declared twice (attribute and instance property), and the property was read from an uninitialized object, which fails for auto-property and field initializers. It is now one static abstract Nsid on IXrpcEndpoint. Assembly scanning calls AddXrpcEndpoint<T> itself, so the two registration paths cannot disagree. Registration rejects handlers with no shape, several shapes, a null NSID, or a conflicting NSID. Query parameters were bound by serializing a Dictionary to a JSON string, so a single ?uris=x became a string and failed to bind to a list. They now bind by the declared property type, from the serializer's contract, written straight into a reused buffer. Identifier types go through their parsers, and failures answer InvalidRequest naming the parameter. Every exception now goes through one filter that writes the XRPC envelope. Unknown ones answer InternalServerError without their message and are logged. New shapes: IXrpcProcedure<TOutput>, IXrpcProcedureVoid, and IXrpcBlobProcedure<TOutput> with XrpcBlobInput. The five reflection wrappers collapse into one table and one MakeGenericMethod per registration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
@claude /review
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
💬 Verdict: comment — notes only, nothing blocking.
This is a substantial, well-executed refactor. The static-abstract-NSID approach fixes a real bug (uninitialized-object reads failing for auto-properties), the query binder is a solid design (reads the serializer's own contract instead of duplicating type logic), and the exception-handling/fallback-route design closely and correctly follows the described ASP.NET Core routing semantics (a route with no HTTP-method constraint suppresses the router's own 405, so the fallback has to produce it — this matches actual ASP.NET Core
HttpMethodMatcherPolicybehavior). Test coverage is extensive and specifically targets the tricky bits (thread-static buffer reuse under concurrency, group convention propagation to the fallback, idempotent registration, etc.).I traced through the registration/dedup logic, the query-binding JSON round-trip, the error envelope writing, and the blob streaming paths and didn't find a functional break in the common cases. One real, if narrow, correctness gap: NSID uniqueness is enforced case-sensitively at registration time (
Nsidis a record with ordinalValueequality), but the unmatched-route lookup builds its dictionary withStringComparer.OrdinalIgnoreCase. Two handlers registered for NSIDs that differ only by case would both passXrpcEndpointRegistry.Add(record equality says they're different), then blow up with an unhandledArgumentExceptionfromToDictionaryinsideMapXrpcEndpoints()— a confusing crash, not the fail-fastInvalidOperationExceptionmessage the rest of the registration path promises. Given NSIDs are conventionally globally unique, this is unlikely to bite anyone in practice, but it's worth a one-line comparer fix inAdd()for consistency.Minor secondary note (not blocking):
XrpcErrorResponse.Unmatched's fallback delegate is not wrapped byXrpcErrorResponse.Handle, so an unexpected exception thrown inside it (however unlikely, given the logic is pure) would bypass the XRPC JSON envelope and theATProtoNet.Server.Xrpclogging path that every other endpoint failure goes through.⏱ 3m 15s · 6 turns · tokens: 102.3k in / 21.1k out (+475.8k cache-read)
📜 Full session transcript
@ -0,0 +59,4 @@public static RequestDelegate Unmatched(IEnumerable<XrpcEndpointRegistration> registrations){// Case-insensitive, as route matching of the registered paths is.var methods = registrations.ToDictionary(r => r.Nsid.Value, r => r.HttpMethod, StringComparer.OrdinalIgnoreCase);XrpcEndpointRegistry.Add(inXrpcEndpointRegistration.cs) treats two NSIDs as conflicting only when they are equal underNsid's ordinal, case-sensitive record equality, so two handlers registered for NSIDs differing only by case both succeed at registration time. This line then buildsmethodswithStringComparer.OrdinalIgnoreCase, soToDictionarythrows an unhandledArgumentException("An item with the same key has already been added") the first timeMapXrpcEndpoints()runs against such a registry — instead of the clearInvalidOperationExceptionthe rest of the registration path uses for conflicts. Either makeAdd()'s conflict check case-insensitive (matching what route matching and this lookup actually do), or build this dictionary with the same comparer used for uniqueness.Both review findings are addressed in
20cbade:InvalidOperationException(testAddXrpcEndpoint_NsidDifferingOnlyInCase_ThrowsLikeAnyConflict).XrpcErrorResponse.Handle, so its failures get the envelope and are logged (testHandle_AnyDelegate_MapsItsExceptionToTheEnvelopeAndLogsIt).@claude /review
23b0d0c7c120cbade4db🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
Re-review: both findings from my previous COMMENT pass are fixed in this commit, each with a targeted regression test.
XrpcEndpointRegistry.Add(in the newXrpcEndpointRegistration.cs) now comparesNsid.ValuewithStringComparison.OrdinalIgnoreCasebefore allowing registration, matching theOrdinalIgnoreCasedictionary the unmatched-route fallback builds. Two handlers differing only by case now fail fast with a clearInvalidOperationExceptionnaming both handlers and both spellings, instead of the previous unhandledArgumentExceptionfromToDictionary. Verified viaAddXrpcEndpoint_NsidDifferingOnlyInCase_ThrowsLikeAnyConflict.XrpcErrorResponse.Handlenow takes anyRequestDelegate, andMapXrpcEndpoints()wraps the group'sMapFallbackdelegate with it exactly like every registered endpoint, so an unexpected exception in the unmatched-route path now answers the standard 500 envelope and is logged (with a request-scoped message since there's no single NSID to name). Verified viaHandle_AnyDelegate_MapsItsExceptionToTheEnvelopeAndLogsIt.I re-traced the registration/dedup path, the fallback's dictionary construction, and the
Handlewrapper's exception switch and didn't find any new correctness issues introduced by this fix-up commit. The rest of the refactor (query binder, blob streaming, route-group metadata propagation, generic registration table) is unchanged from the version I reviewed previously and remains sound. No blocking issues remain.⏱ 0m 30s · 3 turns · tokens: 83.6k in / 2.8k out (+236.5k cache-read)
📜 Full session transcript
💬 Verdict: comment — the reviewer did not submit a structured review; the raw output follows.
Let me check the serializer defaults, the exception type, and whether the SDK is available to run the tests.
⏱ 30m 2s · 8 steps · $0.0356 · tokens: 71.2k in / 633 out (+293.8k cache-read) · Go: 5h 6% · wk 98% · mo 81%
📜 Full session transcript
🔧 Let opencode-bot fix this — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do.