@@ -17,7 +17,7 @@ import { parseXmlToObject } from "./xml"
1717import { extractInlineArgPaths } from "./graphql-inline"
1818
1919export interface OperationInfo {
20- protocol : "graphql" | "jsonrpc"
20+ protocol : "graphql" | "jsonrpc" | "trpc" | "grpc-web"
2121 operation : string // human label, e.g. "mutation:deleteUser", "user.delete"
2222 opKeyHash : string // 16-char dedup discriminator (values stripped)
2323}
@@ -444,6 +444,12 @@ function parseMultipartFields(body: string): BodyField[] {
444444 * before the parse was unified (guarded by a golden characterization test).
445445 */
446446export function bodyKeyShapeHash ( body : string | undefined , contentType : string | undefined ) : string | undefined {
447+ const ct = ( contentType ?? "" ) . toLowerCase ( )
448+ // gRPC-Web binary bodies are not JSON-parseable; provide a stable shape hash so
449+ // the endpoint does not fragment per body value (fallback would be value-bearing bodyHash).
450+ if ( ct . includes ( "grpc-web" ) || ct . includes ( "application/grpc" ) ) {
451+ return sha16 ( "grpc-web-shape" )
452+ }
447453 const parsed = parseBody ( body , contentType )
448454 switch ( parsed . kind ) {
449455 case "json" : {
@@ -472,13 +478,92 @@ export function bodyKeyShapeHash(body: string | undefined, contentType: string |
472478 * plain REST (the caller then falls back to body_hash/query_hash dedup).
473479 * Deterministic, pure — safe for the tier0 parser.
474480 */
481+ function trpcFrom ( query : string , path : string ) : OperationInfo | undefined {
482+ // tRPC is an operation-over-URL protocol like GraphQL: procedures live in the
483+ // path after /trpc/ (comma-separated, batch=1) and inputs in query `input`.
484+ // Normalize to per-operation identity so /trpc/a,b?batch=1 does not fragment.
485+ const m = path . match ( / \/ t r p c \/ ( [ ^ ? ] + ) / i)
486+ if ( ! m ) return undefined
487+ const rawProcs = m [ 1 ] !
488+ const procedures = rawProcs
489+ . split ( "," )
490+ . map ( ( p ) => p . trim ( ) )
491+ . filter ( Boolean )
492+ . sort ( )
493+ if ( procedures . length === 0 ) return undefined
494+ const params = new URLSearchParams ( query )
495+ const inputRaw = params . get ( "input" )
496+ let keyShape : string [ ] = [ ]
497+ if ( inputRaw ) {
498+ try {
499+ const parsed = JSON . parse ( inputRaw )
500+ // tRPC batch wraps under numeric keys: {"0":{"json":{...}}}
501+ // Unwrap to get real input shape; values stripped.
502+ const toShape = ( v : unknown ) : string [ ] => {
503+ if ( ! v || typeof v !== "object" ) return [ ]
504+ const obj = v as Record < string , unknown >
505+ const numericKeys = Object . keys ( obj ) . filter ( ( k ) => / ^ \d + $ / . test ( k ) )
506+ if ( numericKeys . length > 0 ) {
507+ const merged : Record < string , unknown > = { }
508+ for ( const k of numericKeys ) {
509+ const j = ( obj [ k ] as Record < string , unknown > | undefined ) ?. json
510+ if ( j && typeof j === "object" ) Object . assign ( merged , j )
511+ else if ( j ) merged [ k ] = j
512+ }
513+ return keyPaths ( merged ) . sort ( )
514+ }
515+ const j = obj . json
516+ if ( j && typeof j === "object" ) return keyPaths ( j ) . sort ( )
517+ return keyPaths ( obj ) . sort ( )
518+ }
519+ // For batch, shape is union of per-procedure shapes
520+ if ( procedures . length > 1 ) {
521+ const perProc = Object . keys ( parsed as Record < string , unknown > ) . sort ( )
522+ const all : string [ ] = [ ]
523+ for ( const k of perProc ) all . push ( ...toShape ( ( parsed as Record < string , unknown > ) [ k ] ) )
524+ keyShape = [ ...new Set ( all ) ] . sort ( )
525+ } else {
526+ keyShape = toShape ( parsed )
527+ }
528+ } catch {
529+ // ignore parse failure
530+ }
531+ }
532+ const label = procedures . length === 1 ? procedures [ 0 ] ! : `batch[${ procedures . join ( "," ) } ]`
533+ const keyParts = [ "trpc" , procedures . join ( "," ) , keyShape . join ( "," ) ]
534+ return { protocol : "trpc" , operation : label , opKeyHash : sha16 ( keyParts . join ( "<|>" ) ) }
535+ }
536+
475537export function extractOperation ( input : {
476538 method : string
477539 bodyContentType ?: string
478540 body ?: string
479541 query ?: string // raw URL query string (without leading '?')
542+ path ?: string // canonical path for tRPC detection
480543} ) : OperationInfo | undefined {
481544 const ct = input . bodyContentType ?? ""
545+ const lowerCt = ct . toLowerCase ( )
546+
547+ // tRPC — path-based procedures, query-carried inputs. Check before GraphQL
548+ // because tRPC may also have ?input JSON.
549+ const pathForTrpc = input . path ?? ""
550+ if ( pathForTrpc . toLowerCase ( ) . includes ( "/trpc/" ) ) {
551+ const trpc = trpcFrom ( input . query ?? "" , pathForTrpc )
552+ if ( trpc ) return trpc
553+ }
554+ // Also detect tRPC from query when path not supplied (caller passes only query)
555+ if ( input . query && input . query . includes ( "batch=" ) && pathForTrpc === "" ) {
556+ // opportunistic: if query looks like trpc batch, try generic
557+ const maybe = trpcFrom ( input . query , "/trpc/" + ( new URLSearchParams ( input . query ) . get ( "trpc" ) ?? "" ) )
558+ if ( maybe ) return maybe
559+ }
560+
561+ // gRPC-Web — content-type based, no body JSON. Alias the path as operation so
562+ // each service/method is its own endpoint, but body values do not fragment.
563+ if ( lowerCt . includes ( "grpc-web" ) || lowerCt . includes ( "application/grpc" ) ) {
564+ const op = ( input . path ?? "" ) . replace ( / ^ \/ / , "" ) || "grpc"
565+ return { protocol : "grpc-web" , operation : op , opKeyHash : sha16 ( "grpc-web<|>" + op ) }
566+ }
482567
483568 // GraphQL-over-GET: the query lives in the URL, not the body. (queryKeyHash
484569 // keys on param names only, so without this `?query=A` and `?query=B` would
@@ -487,6 +572,11 @@ export function extractOperation(input: {
487572 const params = new URLSearchParams ( input . query )
488573 const q = params . get ( "query" )
489574 if ( q && q . includes ( "{" ) ) return graphqlFrom ( q , params . get ( "variables" ) )
575+ // tRPC GET fallback when extractOperation called without path (parser passes path)
576+ if ( pathForTrpc . toLowerCase ( ) . includes ( "/trpc/" ) ) {
577+ const t = trpcFrom ( input . query , pathForTrpc )
578+ if ( t ) return t
579+ }
490580 return undefined
491581 }
492582
0 commit comments