@@ -16,7 +16,12 @@ import {
1616 createDuplexSession ,
1717 getWorkerLifeSignal ,
1818} from "#rpc" ;
19- import { WorkerCrashedError } from "@/utils/errors-client" ;
19+ import {
20+ WorkerCrashedError ,
21+ RequestValidationFailedError ,
22+ } from "@/utils/errors-client" ;
23+ import { formatZodError } from "@/utils/zod-error" ;
24+ import { z } from "zod" ;
2025import {
2126 nowMs ,
2227 shouldProfile ,
@@ -50,6 +55,47 @@ function getNextCommandId() {
5055 return commandCounter ;
5156}
5257
58+ // On a failed request parse, re-validate against the single `requestSchema`
59+ // member that owns the request's `type` so the error names the actual field
60+ // rather than reporting a generic union failure. `loadModel` (a nested union
61+ // with no top-level `type` literal) is already validated field-level in
62+ // `client/api/load-model.ts`, so it falls back to the union error here.
63+ interface RequestMemberIntrospect {
64+ shape ?: { type ?: { value ?: unknown } } ;
65+ options ?: RequestMemberIntrospect [ ] ;
66+ }
67+
68+ function memberDiscriminator ( option : RequestMemberIntrospect ) : string | undefined {
69+ const direct = option . shape ?. type ?. value ;
70+ if ( typeof direct === "string" ) return direct ;
71+ const nested = option . options ?. [ 0 ] ?. shape ?. type ?. value ;
72+ return typeof nested === "string" ? nested : undefined ;
73+ }
74+
75+ function pinpointRequestError ( request : unknown , fallback : z . ZodError ) : z . ZodError {
76+ const type = ( request as { type ?: unknown } | null ) ?. type ;
77+ if ( typeof type !== "string" ) return fallback ;
78+ for ( const option of requestSchema . options ) {
79+ if ( memberDiscriminator ( option as RequestMemberIntrospect ) !== type ) continue ;
80+ const result = ( option as z . ZodType ) . safeParse ( request ) ;
81+ return result . success ? fallback : result . error ;
82+ }
83+ return fallback ;
84+ }
85+
86+ function parseRequest < T extends Request > ( request : T ) : Request {
87+ try {
88+ return requestSchema . parse ( request ) ;
89+ } catch ( error ) {
90+ if ( error instanceof z . ZodError ) {
91+ throw new RequestValidationFailedError (
92+ formatZodError ( pinpointRequestError ( request , error ) ) ,
93+ ) ;
94+ }
95+ throw error ;
96+ }
97+ }
98+
5399// Race in-flight reply/stream pulls against the worker-life signal —
54100// bare-rpc's `_onerror` does not iterate `_outgoingRequests`, so without
55101// this they hang on a dead socket.
@@ -234,7 +280,7 @@ async function sendBase<T extends Request>(
234280 options ?: RPCOptions ,
235281 signalDisable : boolean = false ,
236282) : Promise < Response > {
237- const parsedRequest = requestSchema . parse ( request ) ;
283+ const parsedRequest = parseRequest ( request ) ;
238284 const req = rpc . request ( getNextCommandId ( ) ) ;
239285 logger . debug ( "RPC Client sending:" , summarizeRequest ( request ) ) ;
240286 const payloadObj = signalDisable
@@ -272,7 +318,7 @@ async function sendProfiled<T extends Request>(
272318
273319 try {
274320 const zodStart = nowMs ( ) ;
275- const parsedRequest = requestSchema . parse ( request ) ;
321+ const parsedRequest = parseRequest ( request ) ;
276322 timings . requestZodValidationMs = nowMs ( ) - zodStart ;
277323
278324 const req = rpc . request ( getNextCommandId ( ) ) ;
@@ -352,7 +398,7 @@ async function* streamBase<T extends Request>(
352398 options : RPCOptions = { } ,
353399 signalDisable : boolean = false ,
354400) : AsyncGenerator < Response > {
355- const parsedRequest = requestSchema . parse ( request ) ;
401+ const parsedRequest = parseRequest ( request ) ;
356402 const req = rpc . request ( getNextCommandId ( ) ) ;
357403 logger . debug ( "RPC Client streaming:" , summarizeRequest ( request ) ) ;
358404 const payloadObj = signalDisable
@@ -409,7 +455,7 @@ async function* streamProfiled<T extends Request>(
409455
410456 try {
411457 const zodStart = nowMs ( ) ;
412- const parsedRequest = requestSchema . parse ( request ) ;
458+ const parsedRequest = parseRequest ( request ) ;
413459 timings . requestZodValidationMs = nowMs ( ) - zodStart ;
414460
415461 const req = rpc . request ( getNextCommandId ( ) ) ;
@@ -523,7 +569,7 @@ async function duplexBase<T extends Request>(
523569 signalDisable : boolean ,
524570 timeout ?: number ,
525571) : Promise < DuplexSession > {
526- const parsedRequest = requestSchema . parse ( request ) ;
572+ const parsedRequest = parseRequest ( request ) ;
527573 logger . debug ( "RPC Client duplex:" , summarizeRequest ( request ) ) ;
528574
529575 const payloadObj = signalDisable
@@ -554,7 +600,7 @@ async function duplexProfiled<T extends Request>(
554600
555601 try {
556602 const zodStart = nowMs ( ) ;
557- const parsedRequest = requestSchema . parse ( request ) ;
603+ const parsedRequest = parseRequest ( request ) ;
558604 timings . requestZodValidationMs = nowMs ( ) - zodStart ;
559605
560606 logger . debug ( "RPC Client duplex:" , summarizeRequest ( request ) ) ;
0 commit comments