Repository navigation
perf(sdk,auth,utils): build FrontMCP's own zod schemas on first use - #1010
Conversation
internalZ defers every schema factory, leaves included. A stand-in builds its leaf children with it, so a built schema holds Zod's own leaves and parses on Zod's fast path, and it builds without Zod's cycle-check walk, which would otherwise build every stand-in under a container on its first parse. The public z is unchanged: its leaf factories are still Zod's own. Stand-ins share one Proxy handler and drop their factory once built. forceMaterialize also follows catchall, rest and pipe in/out.
The schemas in sdk, auth and utils now come from internalZ, so a server builds a schema the first time it is used, leaves included. The recursive authorities policy schema stays on z, which keeps Zod's cycle support. A parity spec loads the three packages twice, once as shipped and once with internalZ replaced by z, and checks that every exported schema accepts and rejects the same inputs with the same issues and messages.
…alZ build, and convert merged schemas - A stand-in from z or lazyZ() that Zod builds while it builds one of internalZ's (.merge(), record() and discriminatedUnion() read their arguments) is built with Zod's memoizer, so a recursive schema of the caller's keeps its cycle support. The walk-less memoizer carries a registry symbol, so another copy of lazy-zod in the process recognizes it too. - forceMaterialize no longer assigns to the catchall getter that .merge() defines, which made toJSONSchema throw on every merged schema. - Tests pin the Zod memoizer internals the swap relies on and the fallback when they are gone, and compare chained internalZ schemas with Zod's own toJSONSchema.
…umers derive from them - A recursive exported schema also gets a sample that refers back to itself, so one moved to internalZ, which builds without Zod's cycle support, fails the spec. - Every exported schema is compared on what a consumer derives from it (.optional(), .array(), .or(), .extend(), .merge(), .partial(), .strict(), .pick(), .omit(), a spread shape, a discriminated union) and on what a consumer reads from it (type, class, instanceof, description, meta, optionality, shape, lazy-zod's and Zod's own toJSONSchema).
…perf/p03-lazy-leaf-zod
|
@coderabbitai review |
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 46 minutes. View limit details
|
Action not completed
|
…p03-lazy-leaf-zod
|
@coderabbitai review |
Action not completed
|
…p03-lazy-leaf-zod
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T17:50:44.724Z |
…p03-lazy-leaf-zod
…p03-lazy-leaf-zod
Stacked on #1011 (which adds
internalZ; split out so each PR stays under CodeRabbit's 150-file limit)Closes #949
What and why
Follow-up to the 1.10.9 performance work (#914, #919–#925, #932, #934–#943), on the follow-up stack's P03. After
startup, a FrontMCP server held 5,529 Zod schema instances plus 953 lazy stand-ins, which together retained 6.32 MB.
@frontmcp/lazy-zodalready deferred compound schemas (z.object(),z.union(), …), but leaf schemas(
z.string(),z.enum(), …) were built when their module loaded, and 440 of the 953 stand-ins were built duringstartup anyway.
Those 440 aren't built by FrontMCP code reading metadata. They come from Zod itself. Zod 4.6 attaches a
memoizer to every container (object, array, tuple, record, map, set), and the first parse of each runs
isRecursive,which walks the container's whole subtree to look for cycles and reads
_zodon every child. Parsing the@FrontMcpconfig (219 stand-ins built),@App(93),@Flow(57) and@Tool(44) metadata therefore built everyschema under them, including optional sections the input doesn't have, such as
auth,redisandjobs.This PR covers both items of the issue's proposal. Item 1 is the internal export, used for the schemas in sdk, auth
and utils. Item 2 is startup no longer building schemas it only walks: the walk is skipped for FrontMCP's own
schemas.
What changed
@frontmcp/lazy-zodinternalZ, a new export. It defers every schema factory: the compound oneszdefers, plusstring,number,int,boolean,bigint,symbol,date,undefined,null,void,any,unknown,never,nan,literal,enum,nativeEnum,url,email,uuid,array,set,map,partialRecord,optional,nullable,lazy,function,custom,instanceofandpreprocess. Anything else passes through to Zod.therefore holds Zod's own leaves, and parses on Zod's compiled fast path. Leaving the stand-ins in place would
make a five-field object parse about 4.5× slower in a microbenchmark (41 ns → 185 ns, about 30 ns per proxied
child). On the
benchmark it would raise proxy reads per
tools/callfrom 17 to 59. Compound children stay deferred until aparse reaches them.
attach, swapped in throughz.config()for the duration of the build and restored afterwards, even when the build throws. A parse thenbuilds only what it touches. Zod's memoizer stays in place for every other schema, and
z's schemas keep it:a stand-in from
zorlazyZ()that Zod builds while it builds one of FrontMCP's (.merge(),record()anddiscriminatedUnion()read their arguments) is built with Zod's memoizer, from this copy of lazy-zod or anotherone in the process. When Zod has no memoizer to swap, the build is left to Zod.
zis unchanged. Its leaf factories are Zod's own functions and return Zod's own schemas, whichnew tests pin. The README lists
internalZas internal. It isn't re-exported from@frontmcp/sdk.zandinternalZalike, share one Proxy handler instead of allocating ahandler and three trap closures each, and drop their factory and its captured arguments once built.
forceMaterialize, whichtoJSONSchemaruns first, also followscatchall, tuplerestand pipein/out.It leaves alone the
catchallgetter that.merge()defines, so merged schemas still convert.sdk,auth,utilsinternalZ as z: 117 in sdk, 18 in auth, 4 in utils. Only theimport line changes.
z:auth/src/authorities/authorities.schema.ts:authoritiesPolicySchemais recursive (z.lazyrefers toitself), and only Zod's memoizer lets it parse a policy that refers to itself. A new test pins that it still
does. With
internalZit overflows the stack.auth/src/session/vault-encryption.ts(two small schemas): the pre-commit hook's lint config rejects an existingthrowwithoutcausein it, and changing that error is outside this PR.@frontmcp/lazy-zodimportzonly as a type, or import noz, so nothingchanges for them at runtime. That leaves no runtime schema module in the three packages unswapped, hence
Closes.Results
The benchmark's FrontMCP implementation (
frontmcp build --target node, oneaddtool, logging off), run withnodeon Node.js 24.22.1, againstnpm packed builds oforigin/main(42b36fa1b, 1.10.16) and of this branch,installed into two scratch copies and built with
npm run build. Both copies resolvezod4.6.7. Every run used thebenchmark's own harness functions, interleaved or in random order, and every
tools/callreturned the spec's result.Apple M4 Pro, with other jobs running on the machine: the 1-minute load average was 25 to 100 across the sessions, and
macOS was swapping, so the timing rows carry wide intervals. MB is 10^6 bytes; the intervals are 95% bootstrap
intervals of the difference in medians.
tools/call. Reachable heap is the selfsize of everything reachable from the root over non-weak edges. The Zod set is every object with an own
_zodproperty, plus lazy stand-ins; its retained size is what becomes unreachable when the whole set is removed. Three
interleaved runs of each build, all within 5 KB of each other.
process.memoryUsage().heapUsed700 ms after spawn, after the first call. RSS is the processtree's, 1 s after the first call, read with the harness's
rssOfTree. 30 interleaved runs of each build with noforced GC (load 39 to 80), and 20 with two
gc()calls at the 700 ms mark (load 32 to 37).process.cpuUsage()in the server at the 700 ms mark: startup plus the first call. The same 30 runs.gettrap in scratch copies of the installed lazy-zod,over 2,000 calls after 2,000 warm-up calls.
coldStartOnce, spawn to the firsttools/callresponse withinitializeincluded.One discarded start per server, then 16 rounds of 4 starts per transport in random order: 64 starts each, in two
sessions.
maintools/callAgainst the issue's best case, measured against 1.10.9-rc.4 plus #942 with every factory deferred through the public
z:heapTotaldoesn't move either),so the lower heap shows in RSS only once a GC has run.
is built then, as one Zod instance; on
mainit was built when its module loaded.tools/call, read in the server around 3,000 calls after 2,000 warm-up calls, 8interleaved runs of each build (load 59 to 68): median 400.2 µs [345.8–625.0] on
main, 389.8 µs [319.1–500.0]with this PR. No measurable change.
2,187 Zod instances, 4.14 MB retained, 1,124 stand-ins built.
Two variants were measured and dropped:
tools/callinstead of 17.User-facing change
z,eagerZ,lazyZ,toJSONSchemaand the schemas@frontmcp/sdk,@frontmcp/authand@frontmcp/utilsexport accept and reject the same inputs, with the same issues and messages.@frontmcp/lazy-zodgains aninternalZexport that its README marks as internal and not part of the public API;@frontmcp/sdkdoesn't re-export it.toJSONSchemafix: lazy-zod'stoJSONSchemaconverts a schema whosecatchall, tuplerestor pipe holds astand-in from
z, for exampleeagerZ.object({...}).catchall(z.object({...}).optional()). It threwTypeError: Cannot set properties of undefined (setting 'ref')before.holds 2.35 MB less reachable heap after startup. Schemas built from those exports, for example with
.extend(), arebuilt without Zod's cycle-check walk: a recursive schema made by extending one of them, such as
Exported.extend({ next: z.lazy(() => Node).optional() }), overflows the stack on a cyclic JavaScript object, asevery recursive schema did before Zod 4.6. Inputs parsed from JSON can't be cyclic. A recursive schema built with
zkeeps the walk, including when it's passed to one of those exports' methods.z,eagerZandlazyZreference describesz, whichdoesn't change.
Tests
libs/lazy-zod/src/__tests__/internal-z.spec.ts(167 tests).z: every leaf factory is Zod's own function and returns Zod's own schema; only the compoundfactories are deferred.
internalZ, table-driven over every factory it defers: a stand-in builds nothing until used, accepts andrejects the same inputs as Zod with the same issues and message, and converts to the same JSON Schema as Zod's
own
toJSONSchema. Then 16 chained cases, compared the same way: string and number checks,default,describe,nullish,refine,superRefine,transform,pipe,or/and,array(),catch,extend/pick/omit/partial/strict,merge,catchall,exclude, and nested optional sections.children stay deferred until a parse reaches them; an already-built child is reused; Zod's memoizer stays in place
for other schemas, which still parse a cyclic input; the memoizer is restored when a build throws.
z, passed to.merge(),record()ordiscriminatedUnion()ofinternalZ's and built with it, still parses a cyclic input, and so does one from asecond copy of lazy-zod (
jest.isolateModules).z.config().memoizer,z.memoizer(), to every container and it hasattach;with that internal renamed away,
internalZbuilds with whatever Zod does and still validates the same.instanceof,.options,.shape,._def,.isOptional(),.descriptionafter.extend(), discriminated unions, atoJSONSchema→z.fromJSONSchemaround trip, andinternalZ.infer/internalZ.ZodType.libs/lazy-zod/src/__tests__/materialization.spec.ts:toJSONSchemaconverts a merged schema, fromzandfrom Zod, to what Zod's own
toJSONSchemagives.libs/sdk/src/__tests__/internal-z-schema-parity.spec.ts(527 tests).@frontmcp/utils,@frontmcp/authand@frontmcp/sdktwice: as built, and withinternalZmocked to thepublic
z, i.e. howmainbuilt them.sample with each key removed and with each key given a wrong type, and generic probes. That is 5,270 inputs, 879
of them accepted. It checks that both builds give the same success, data, issues and error message.
schema moved to
internalZfails the spec. It finds three,authoritiesPolicySchema,authoritiesMetadataSchemaandauthoritiesConfigSchema; withauthorities.schema.tsoninternalZ, their sixtests fail.
.optional(),.array(),.or()andz.object({ field }); for object schemas also.extend(),.merge(),.partial(),.strict(),.pick(),.omit(),z.object({ ...shape })and, with a literal key,z.discriminatedUnion(). Each validates the sameinputs and converts to the same JSON Schema with both builds. It also compares
_zod.def.type, the class name,instanceof ZodType,.description,.meta(),isOptional(),isNullable(), the shape's keys and types,lazy-zod's
toJSONSchema(output and input), and Zod's owntoJSONSchemawherever themain-style buildconverts.
internalZ's array arguments (enum values, union options) in the builtpackage makes 105 of its 264 tests fail.
libs/auth/src/authorities/__tests__/authorities.schema.cycles.spec.ts: a policy that refers to itselfstill parses, and keeps the cycle. It passes with
zand overflows the stack withinternalZ, which is why thatfile stays on
z.nx run-many -t lint,test,build -p lazy-zod,utils,auth,sdk: all pass, with no lint errors.internal-z.tsandmemoizer.ts100%.nx run-many -t testover the other 23 publishable projects: all pass.plugin-dashboard 226, plugin-remember 515, plugin-cache 202, plugin-skilled-openapi 433, plugin-codecall 1,208,
react 758, skills 249, plugin-webmcp 91, plugin-approval 427, plugin-feature-flags 239, cli 2,115,
observability 498 and nx-plugin 424.
node --test scripts/*.test.mjs(the Worker export-condition and browser-build guards among them): 84 pass.authorities 43 and standalone 53.
@frontmcp/observabilitybuilt, which its project doesn't list as adependency. They passed once it was built.
tsc --noEmit: 0 errors in the lazy-zod lib and spec configs, the utils and auth lib configs, and the sdk liband spec configs.
scripts/fix-unused-imports.mjs: Prettier passes on the changed files, and the script changesnothing.
import { z } from '@frontmcp/lazy-zod'lines inside the JSDoc examples ofagent/index.ts,builtin/config/index.tsandbuiltin/config/config.plugin.ts. Those are user-facing docs, so they are back toz.