Repository navigation
feat(sdk): offload CPU-heavy work to a worker-thread pool - #1009
Conversation
…ffload() and @resource({ worker: true }) Every execution context gets this.offload(moduleRef, args, { budgetMs, signal }): it runs an exported function of another module in a worker thread of the scope's pool and resolves with its result. @resource, @ResourceTemplate, resource() and resourceTemplate() take worker: true | { budgetMs }, and the read-resource flow then runs execute() in a worker. - The pool is OffloadService, a provider of every scope: it loads the pool and worker_threads on the first call, refuses on any runtime but Node with OffloadUnsupportedRuntimeError, and closes in onShutdown(), so disposing the scope terminates the workers and rejects the calls in flight. - New option offload: { maxWorkers, idleTimeoutMs, budgetMs, env }, validated by Zod; an invalid value fails @FrontMcp with InvalidDecoratorMetadataError. - Workers start on demand, are reused, capped at maxWorkers (calls past it queue) and stopped once idle. The budget counts from the moment the function starts; past it the worker is terminated, the call rejects with OffloadBudgetExceededError and a warning is logged once per offloaded function. The call's signal (this.signal by default) terminates the worker. - A worker gets only its arguments and the environment variables named in offload.env (default NODE_ENV): no DI container, session, auth info or request headers; values an --env-file loaded are removed. Arguments and results must be structured-cloneable (OffloadNotCloneableError). A thrown error keeps its name, message, stack and code, and a public one stays public. - A worker: true resource is built in its worker from the module its decorator ran in; this.get(), this.scope, this.auth and the request context throw there, and its log lines reach the server's logger. A worker marks its thread so an imported @FrontMcp class serves nothing, sends console output to stderr, and registers tsx when the server runs under it. - this.signal moves to ExecutionContextBase; resource reads now get the request's signal.
…ead of throwing from a worker event
…ge's module and harden the offload pool - A worker error is public only when it is a FrontMCP McpError (by its class brand) with isPublic, as on the main thread; an error that only has an isPublic property no longer reaches the client in production. A public one keeps its JSON-RPC code, data and remedy, so a ResourceNotFoundError read in a worker answers -32002 with data.uri. - The worker calls only a function its module exports, never one the exports inherit (toString, call). - captureCallerFile no longer skips node_modules frames: a worker resource declared in an installed package is recorded as that module, not the module that required the package (CommonJS), and is found at all under ESM. - Worker resources are keyed by module, name and URI or URI template, so two of one module or bundle that share a name are not read as each other. - A worker's stdout is forwarded chunk by chunk instead of piped, so eleven or more workers no longer trip a MaxListenersExceededWarning on process.stderr. - A tsx without the cjs/esm registration APIs is skipped in the worker instead of failing every offloaded call. - The read flow loads its worker helper on demand, which takes about 570 bytes off the browser bundle. - The refusal for a resource with no module on the stack says a bundle that inlines @frontmcp/sdk has none. - Catalog: which errors stay public, installed packages and inlined-SDK bundles, preloads in the server's flags, N x maxWorkers threads with splitByApp, the tsx fallback, and a troubleshooting row.
…m11-worker-offload # Conflicts: # apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json # libs/sdk/src/common/metadata/front-mcp.metadata.ts # libs/sdk/src/common/tokens/front-mcp.tokens.ts # libs/sdk/src/common/types/options/index.ts
…m11-worker-offload
|
@coderabbitai review |
|
Warning Review limit reachedThis review is too large to run within your organization's remaining usage spending cap. Raise or remove your spending cap in the billing tab, then retry. Next included review available in 32 minutes. View limit details
|
Action not completed
|
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T16:52:59.210Z |
Stacked on #1006
What and why
Proposal item 4.2: offload CPU-heavy work so the server keeps answering.
A FrontMCP server answers every request on one event loop. A tool or resource that parses a large file, walks a
repository or computes for seconds blocks it, and every other call waits,
pingincluded. There was no way to runsuch work off the event loop short of hand-rolling
worker_threads, which also meant no budget, no cancellation, noshutdown and no guarantee about what reaches the worker.
What this PR changes:
this.offload(moduleRef, args, { budgetMs, signal })onExecutionContextBase, so tools, resources, prompts,agents, jobs and skills all have it. It runs an exported function of another module in a worker thread of the scope's
pool and resolves with what it returns.
moduleRefis{ module, export? }:moduleis an absolute path, afile:URL string or aURL(new URL('./x.js', import.meta.url)); a relative path, a package name or anon-
file:URL is refused withInvalidOffloadCallError, and so are non-arrayargs, a missing export and anon-positive
budgetMs. The type parameter types the arguments and the result(
this.offload<typeof countWords>(ref, [text, 3])).@Resource({ worker: true | { budgetMs } }), and the same on@ResourceTemplate,resource()andresourceTemplate()(validated by Zod in the metadata schemas). The read-resource flow runs as before; only itsexecutestage handsexecute(uri, params)to the pool instead of calling it, so authorities,enabled, every hook,output validation and the answer budget still run on the main thread through the flow. The decorator records the
module it was evaluated in (from V8 call sites, so a compiled build names its
.jsfile even with source maps on, anda module of an installed package is named itself rather than the module that required the package); the worker
imports that module, finds the resource's reader in a registry its own copy of the SDK filled while the module
loaded (keyed by module, name and URI or URI template, so two resources of one module or bundle that share a name stay
apart), builds the resource there and calls
execute(). A bundle that inlines@frontmcp/sdk(a SEA binary,mcpb,lambda,vercel) has no separate module to import, and itsworkerreads are refused withInvalidOffloadCallErrorsaying so; the--target nodebundle keeps the SDK external and works.offload/offload-pool.ts): workers start on demand, are reused, are capped atmaxWorkers(calls pastit queue) and stop after
idleTimeoutMsidle (an idle worker is unref'd, so it never holds the process open). Eachworker runs one call at a time.
loading the SDK does not eat a small budget; loading is bounded by the larger of the call's budget and
offload.budgetMs. Past it the worker is terminated, the call rejects withOffloadBudgetExceededError(
OFFLOAD_BUDGET_EXCEEDED, public, 504) and the server warns, once per offloaded function (module and export, orresource); later overruns of the same one are logged at verbose level.
this.signal) terminates the worker, running or not yet started, and thecall rejects with
OffloadAbortedError(OFFLOAD_ABORTED).this.signalmoves fromToolContext/JobContextupto
ExecutionContextBase, and a resource read now gets the request's signal (the MCP handler'sextra.signal), so atool's timeout and a client's cancel both stop the worker.
process.exit()or an out-of-memory exit in the worker rejects the call withOffloadWorkerErrorand the pool starts a new worker for the next call; so does a worker that cannot start.code. A public FrontMCP error(an
McpError, recognised by its class brand, withisPublic) is rebuilt as aPublicMcpErrorwith its code,status, remedy and JSON-RPC code and data, so a worker resource's
ResourceNotFoundErroris answered with-32002and
data.uri, as an inline read is. Any other error stays internal, as it is on the main thread, even one with anisPublicproperty, so its message is not sent to a client in production.OffloadService(below) checksgetRuntimeContext().runtimefirst and throwsOffloadUnsupportedRuntimeErrornaming the runtime (edge,browser,bun,deno). Nothing importsworker_threadsat module load: the pool module is loaded withimport()on the first call and requiresnode:worker_threads,node:osandnode:urlinside it. The browser bundle gains about 1.5 KB (the option schema,two error classes and the service); the read flow loads its worker helper with
import()only for aworkerresource, and a new
worker offloadentry indemo-e2e-bundle-sizekeeps the pool, the worker source, the flowhelper and the worker-side reader out of the bundle.
OffloadServiceis a GLOBAL provider in every scope's default providers(
useValue), and itsonShutdown()closes the pool, so every path that disposes a scope (graceful HTTP and stdioshutdown,
create()/createDirect()dispose(), the lastconnect()client) terminates the workers; the callsstill running or queued reject with
OffloadAbortedError("the server is shutting down"), and later calls are refused.@FrontMcp({ offload: { maxWorkers, idleTimeoutMs, budgetMs, env } }), a strict Zod schema; aninvalid value fails
@FrontMcpwithInvalidDecoratorMetadataErrornamingoffload.What crosses the worker boundary (also in the catalog and the JSDoc):
args,by structured clone. The worker's
process.envholds only the variables named inoffload.env(default['NODE_ENV']): the pool passesenvtonew Worker()and the worker deletes every other variable before it importsanything, which also drops what
--env-fileloaded (an inherited--env-fileflag re-reads the file in a workereven with
envgiven; found while testing). The worker inherits the server'sexecArgv(loaders, conditions), neverits argv; a preload among those flags (
--require,--import) runs in each worker before the worker clears itsenvironment and sees an
--env-file's variables (passing a filteredexecArgvinstead is refused by Node for V8flags such as
--max-old-space-size, so this is documented rather than changed). No DI container, scope, session, auth info, access token or request headers cross: a worker resource isbuilt with
authInfo: {}and a provider registry whose every method throwsProviderNotAvailableErrornaming theresource, so
this.get(),this.scope,this.authandthis.contextthrow there,this.tryGet()returnsundefined, andthis.fetch()is a plainfetch.OffloadNotCloneableErrorfor a function, a symbol and the like, ineither direction); an error's name, message, stack,
code, whether it is a public FrontMCP error and, if so, itsstatusCode, remedy and JSON-RPC payload; a worker resource's log lines, which the server writes withresource:<name>.not trip a
MaxListenersExceededWarningonprocess.stderr), so aconsole.login offloaded code never corrupts astdio transport.
toString, a function'scall) is refused like a missing one.Two runtime details found while building it:
@FrontMcpfile, or abundle). The worker marks its thread (
Symbol.for('@frontmcp/sdk:offload-worker')) before it imports anything, and@FrontMcpthen records its metadata and serves nothing, as in schema-extract mode.isMainThread), so underfrontmcp devornode --import tsxa worker could not load a decorated.tsresource. When the server'sexecArgvnames tsx, thepool passes the paths of tsx's CommonJS and ES module registration APIs and the worker registers both before it
imports anything. A tsx without those APIs is skipped, so JavaScript modules still run.
Deviations from the brief / PLAN.md:
argsis the array of the function's arguments (spread into the call), typed from the function by the typeparameter.
written with the calling context's logger, so it carries the tool's or resource's name.
workeralso takes{ budgetMs }, since a server-wide budget does not fit every resource.splitByAppapp has its own), so N split apps run up to N ×maxWorkersthreads.Code layout:
offload/holds the service (offload.service.ts, always loaded, tiny), the pool and the worker source(
offload-pool.ts,offload-worker.source.ts, loaded on demand), the worker-resource registry the decorators call(
worker-resources.ts, no runtime imports of the context classes, so the per-module browser build has no evaluationcycle), the reader a worker loads on demand (
worker-resource.reader.ts) and the flow helper the read flow loads ondemand (
read-in-worker.ts).captureCallerFile()joinscaptureCallerDir()incommon/utils/caller-dir.utils.ts.User-facing change
this.offload(moduleRef, args, options?)on every execution context, and the typesOffloadModuleRef,OffloadCallOptionsandOffloadFunctionexported from@frontmcp/sdk.worker?: boolean | { budgetMs?: number }on@Resource,@ResourceTemplate,resource()andresourceTemplate()(typeResourceWorkerOption). Messages:worker.budgetMs must be a whole number of milliseconds,... must be greater than 0.offload: { maxWorkers?, idleTimeoutMs?, budgetMs?, env? }(defaults: available parallelismminus one, between 1 and 4; 30000; 30000;
['NODE_ENV']), strict. Messages:offload.maxWorkers must be at least 1,offload.<field> must be a whole number of milliseconds,... must be greater than 0,offload.env must be a list of environment variable names; an invalid value throwsInvalidDecoratorMetadataErrorforoffload.OffloadBudgetExceededError(OFFLOAD_BUDGET_EXCEEDED, public, 504:Offloaded "<name>" ran past its <n> ms budget and was stopped),OffloadAbortedError(OFFLOAD_ABORTED, public, 499:... was stopped: its call was cancelled/the server is shutting down),OffloadUnsupportedRuntimeError(OFFLOAD_UNSUPPORTED_RUNTIME),InvalidOffloadCallError(INVALID_OFFLOAD_CALL),OffloadNotCloneableError(OFFLOAD_NOT_CLONEABLE),OffloadWorkerError(OFFLOAD_WORKER_FAILED).Offloaded "<name>" (<module>#<export>) ran past its <n> ms budget; its worker was terminated. Later overruns of it are logged at verbose level.this.signalis now declared onExecutionContextBase. In a resource read it was alwaysundefinedand is nowthe request's signal, aborted when the client cancels the read or the connection closes; a resource's
this.fetch()is unchanged (it does not pass the signal). Prompts, agents and skills still get none.offloadorsignalof another type now clasheswith the base class (TypeScript reports it): rename it.
this.offload()orworker: trueruns it in aworker thread, and
pingand other calls keep answering (the e2e measures it).workerreads on the main thread as before; nothing starts a worker until the firstcall that needs one; tool and job
this.signalbehave as before.remedy); any other error thrown there is internal, as inline.
/reference/sdk/tool,/reference/sdk/resource:this.offload(),worker,this.signalon resources), the@FrontMcpconfigurationreference (
offload), the errors reference (the six new errors), and the guides on performance and on running understdio (a worker's console output goes to stderr).
frontmcp-development: newreferences/offload-cpu-work.md(when to offload,this.offload(), module references,budget and abort,
worker: trueand what the worker can and can't reach, what crosses the boundary, theoffloadoption, errors, patterns, checklist, troubleshooting; also which errors stay public across the boundary, installed
packages and bundles that inline the SDK, preloads in the server's flags, N ×
maxWorkersthreads withsplitByApp, a tsx without registration APIs),references/create-resource.md(worker,this.offload(),this.signal),references/decorators-guide.md(offload?andworker?rows),SKILL.md(routing row, list,related skills),
skills-manifest.json(the new reference).create-tool:references/execution-context.md(this.offload()row).Tests
libs/sdk: 45 new tests inoffload/__tests__, 6 added to existing specs):offload/__tests__/offload-pool.spec.ts(27, real worker threads and modules written to a temp dir): result andthread,
file:URL /URL/ CommonJS modules, reuse, themaxWorkerscap and queueing, idle timeout, budgetoverrun (termination, typed error, warn once then verbose, the pool recovers), the budget counted from the start of
the function (a module that takes 400 ms to load, a 400 ms call, a 600 ms budget), the pool default budget, abort of
a running call, of an already-aborted signal and of a queued call, non-cloneable arguments and results, the
function's error, a public FrontMCP error keeping its JSON-RPC code, data and remedy (
toSdkMcpErroranswers-32002withdata.uri, and-32003with the remedy), an error that only claimsisPublicstaying internal, anexport name the exports only inherit refused, a worker's console output forwarded to stderr without a pipe per
worker, a tsx without registration APIs skipped, worker exit and crash, a worker that cannot start, every refused module reference, the environment
(
NODE_ENVonly by default,offload.envnames), a worker resource read with log forwarding, an unregisteredworker resource,
close()andtsxApiOf().offload/__tests__/offload.service.spec.ts(5): refused onedge,browserandbunnaming the runtime withoutstarting a pool, one pool for every call and closed on shutdown, refused after shutdown.
offload/__tests__/offload.context.spec.ts(5, throughcreate()): a tool'sthis.offload(), a tool timeoutterminating its worker (a 1-worker pool serves the next call),
dispose()rejecting the call in flight withOffloadAbortedError, the read-resource flow handing aworkerresource and template to the pool with module,name, URI, params and budget, and a budget error answered by the read.
offload/__tests__/worker-resources.spec.ts(8): the recorded module and key, the reader with no DI container,respond(),fail(),this.get()/this.scoperefused, log forwarding, two resources of one module that share aname read apart, a function-style resource, a resource whose module is unknown, the worker marker.
common/utils/__tests__/caller-dir.utils.spec.ts(+4, one for a module of an installed package being nameditself),
common/decorators/__tests__/front-mcp-stdio.decorator.spec.ts(+2: serves nothing in a worker, an invalid
offloadrefused withInvalidDecoratorMetadataError).--maxWorkers=50%).demo-e2e-local-runtime→worker-offload.e2e.spec.ts(new; the server runs under tsx over HTTP withoffload.maxWorkers: 1): while aworker: trueread keeps its worker busy for 1.5 s, the longestpingstays undera third of that (measured: 16 ms at most over 49 pings), while the same work read inline holds a
pingfor more thanhalf of it (measured: 1509 ms), and the worker read reports
isMainThread: false; a read past its 300 ms budget isrefused with the budget message, after which
ping, the heavy read (on a new worker, as the only one wasterminated) and a tool's
this.offload()all answer. Bounds are relative to the busy time, so a loaded machine doesnot fail them. A
ResourceNotFoundErrorthrown in a worker template is answered with-32002, its message anddata.uri, as inline (it was-32602before the review fix). The project now declares itssdk:builddependency,and its jest config maps
@frontmcp/testing.demo-e2e-local-runtime: 4 suites, 7 tests passed.demo-e2e-dts-consumer→offload-must-compile.ts(new):this.offload<typeof fn>()typing its arguments andresult (and a must-fail for swapped arguments),
workeron@Resource/@ResourceTemplate(a must-fail forworker: 'yes'), theoffloadoption (a must-fail forbudgetMs: '15s'). 18 tests passed.demo-e2e-bundle-size: newworker offloadfeature entry (the pool, the worker source, the flow helper and theworker-side reader stay out); 10 tests passed (create() + one tool: 1,313,024 bytes, budget 1,315,000).
demo-e2e-cloudflare: 2 suites, 16 tests passed (1 suite skipped, as on the base).nx test skills: 249 passed. Lint of the changed files: 0 errors (only warnings on lines this PR does not touch).Prettier: clean.
fix-unused-imports: nothing to remove. Types:nx build sdk(declarations) and a--noEmitcompile of the new spec files.