Skip to content

perf(sdk): set up jobs, workflows, channels, remote MCP, the ESM loader and OAuth flows on first use - #1016

Merged
frontegg-david merged 23 commits into
mainfrom
perf/p04-lazy-sdk-subsystems
Nov 3, 2026
Merged

frontegg-david merged 23 commits into
mainfrom
perf/p04-lazy-sdk-subsystems

Conversation

@frontegg-david

@frontegg-david frontegg-david commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Closes #950

What and why

Follow-up to the 1.10.9 performance work (#914, #919 to #925, #932, #934 to #943). #950 measured that the SDK's CommonJS bundle sets up every subsystem when it loads, used or not, and gave two upper bounds, measured with hacks that broke public behavior: −1.9 ms (HTTP) / −1.8 ms (stdio) for skipping jobs, workflows, channels, remote MCP and the ESM loader, and −3.1 / −3.6 ms for skipping the 8 OAuth flows of a public-mode server. This PR takes both without changing what the SDK exports or which flows a hook can target.

  1. The CommonJS root sets up jobs, workflows, channels, remote MCP and the ESM loader the first time one of their exports is read (the issue's first proposal, lazy getters).
    • The root's value exports of those five subsystems now come from src/subsystems.ts, and src/index.ts still names each of them. The CommonJS build (build-cjs) takes src/lazy-subsystems.ts for that module. It defines each name as a getter that require()s its subsystem's barrel the first time it is read, inside the same bundle, so the class or function is the one every other module of the SDK uses.
    • The swap is an esbuild resolve plugin in libs/sdk/esbuild.cjs.config.js. build-cjs takes its options from that file now (the same minifyWhitespace, outExtension and external list it had in project.json), and the build fails if src/index.ts stops importing ./subsystems. The ESM, browser and browser-module builds, the Worker bundles made from them and the declaration files all keep the static module, so they are as before.
    • Why not the alternatives: esbuild can't split CommonJS output into chunks, and a separate entry point per subsystem would bundle its own copy of the classes it shares with the rest of the SDK. A # subpath import with a build condition would put that # specifier into dist/index.d.ts, which moduleResolution: node (what frontmcp create writes for TypeScript 5) can't resolve. The issue rules out rewriting esbuild's output.
    • Two kinds of module of those subsystems still load at startup, because the startup path uses them: the ESM package-specifier parser (esm-loader/package-specifier.ts and module-specifiers.ts, which the decorators' Tool.esm() and friends call) and three small helpers (job/job.utils.ts, channel/channel.utils.ts, channel/sources/completion-events.ts). Together they took 0.15 ms to set up (instrumented build, below).
  2. The 8 OAuth flows are registered by name and route at startup, and each one's class loads with the first request that reaches its route. LocalPrimaryAuth registers oauthFlows (auth/flows/oauth-flows.ts): one deferredFlow({ name, middleware: { method, path } }, load) per flow, in the order the classes were registered before, so the Express routes keep their order. deferredFlow (flows/deferred-flow.ts) returns a stand-in flow class with that name and route. FlowInstance registers it like any flow, mounts its route, and the first time a request matches its method and path (canActivate, which matchHttp and the Express middleware both call) or the flow runs, it loads the class and takes its metadata, plan and stages. Hooks are read per run, so a plugin's FlowHooksOf('oauth:token') hooks run as before. A request to another route or with another method leaves the class unloaded. This applies to every mode LocalPrimaryAuth serves (public, static, orchestrated local and remote): in the modes that use the OAuth flows, the first request to each route loads its flow.
    • A flow whose class fails to load keeps its route: its run throws the load error, which the Express middleware and the fetch handler answer with a 500, as they answer an error a flow throws, and the error is logged (Unhandled error in flow). Before, the same failure stopped the server at startup.
    • Each of the 8 flow files gains one line, export { OauthTokenFlow }; and so on, because under NodeNext TypeScript types a dynamic import() of a CommonJS-format module's default export as the whole module, while esbuild and Jest hand back the class.
  3. A route guard ran once more on every request of a flow with both middleware.canActivate and a static canActivate: canActivate appended the static guard to the metadata's array. It now builds the list per request. Found by the review below; a loaded OAuth flow's metadata goes through the same path.

The # subpath imports of @frontmcp/utils and @frontmcp/protocol that make an ESM server load zod and part of the MCP SDK twice (found in #1007) are untouched here: this PR doesn't change those imports maps. It stays a follow-up.

Found while working, also unchanged: the ./transport, ./job/tools and ./workflow/tools subpath exports of @frontmcp/sdk point at files the build doesn't emit. In the published 1.10.16, require() of each fails with MODULE_NOT_FOUND (transport/index.js, job/tools/index.js, workflow/tools/index.js) and import with ERR_MODULE_NOT_FOUND (esm/transport/index.mjs and so on); only their .d.ts files ship, so TypeScript compiles the imports. The fix belongs on release/1.10.x.

Results

Apple M4 Pro (14 cores, 48 GiB), macOS 15.6.1, Node.js 24.22.1, on AC power, with other jobs running on the machine (1-minute load average given per run). The server is the benchmark's FrontMCP implementation (frontmcp build --target node), run with node, against npm packed builds of aae293336 (main 1.10.16 with #1002 and #1007) and of this branch at 7e0276974, each installed into a scratch copy of the implementation and built with npm run build (the two dist/node/bench.bundle.js are byte-identical; only @frontmcp/sdk differs). Every run uses the benchmark's harness functions (startServer, coldStartOnce, HttpSession, connectStdio, rssOfTree) in randomized, interleaved rounds. "Per round" is the median, over rounds, of the difference between the two builds' medians in the same round, with its range.

Run A: 16 rounds of 4 starts per build and scenario. 1-minute load average 33.0 to 59.6, median 41.5.

aae293336 This PR Per round Rounds lower
RSS while listening, HTTP (1 s after listening), p50 134.23 MB 132.26 MB −1.54 MB (−3.34 to −0.28) 16/16
RSS after two tools/calls, HTTP 136.29 MB 134.46 MB −1.50 MB (−4.04 to −0.18) 16/16
RSS after two tools/calls, stdio 123.30 MB 121.95 MB −1.56 MB (−3.06 to +0.48) 15/16
Heap used at the first tools/call answer, HTTP 37.65 MB 36.84 MB −0.79 MB (−0.98 to −0.67) 16/16
Heap used at the first tools/call answer, stdio 28.39 MB 27.43 MB −0.75 MB (−1.10 to +1.52) 14/16
CPU time to the first tools/call answer, HTTP 439.7 ms 411.1 ms −30.6 ms (−79.1 to +122.4) 14/16
CPU time to the first tools/call answer, stdio 313.0 ms 306.7 ms −10.8 ms (−76.9 to +48.7) 11/16
Cold start, HTTP, p50 / p90 468.5 / 891.3 ms 510.2 / 907.2 ms −12.5 ms (−431.9 to +995.4) 8/16
Cold start, stdio, p50 / p90 344.4 / 646.8 ms 357.4 / 572.7 ms −23.5 ms (−245.0 to +225.1) 10/16
Spawn to listening, HTTP, p50 481.4 ms 536.6 ms −18.0 ms (−458.0 to +678.5) 9/16
  • RSS is the process tree's (ps), MB is 10^6 bytes. Heap and CPU time are the server's own process.memoryUsage().heapUsed and process.cpuUsage() (user + system, all threads), read in the server by a --require preload when the client has the first tools/call answer (initialize included). Cold start is the benchmark's measure: spawn to the first tools/call answer.
  • At this load, cold start and spawn-to-listening swing by hundreds of milliseconds from round to round, against an expected change of a few milliseconds, so this run can't separate the builds on them: the medians move in opposite directions and fewer than two thirds of the rounds are lower. The CPU time is less exposed to scheduling and is lower in 14 of 16 rounds over HTTP, but its range is still wide. RSS and heap are lower in almost every round.

Run B, module setup, instrumented: a copy of each build whose esbuild __esm init helper records how long each source module took to set up (its own code, without the modules it pulls in and without require()s of other packages), and how long FlowRegistry took to register each flow. 15 starts per build and transport, run one build after the other. 1-minute load average 38.7 to 51.2. Medians:

HTTP, aae293336 → this PR stdio, aae293336 → this PR
Setup of jobs, workflows, channels, remote MCP and the ESM loader 3.67 → 0.06 ms (65 → 5 modules) 4.02 → 0.07 ms (65 → 5 modules)
Setup of the 8 OAuth flow modules 1.46 → 0 ms (8 → 0 modules) 1.46 → 0 ms
Registering the 8 oauth:* flows 0.26 → 0.24 ms 0.12 → 0.05 ms
Setup of every SDK module 44.8 → 36.8 ms 30.1 → 22.3 ms
require('@frontmcp/sdk') 181.0 → 169.6 ms 189.7 → 183.7 ms
SDK modules set up by listening / by the first initialize (exact) 604 → 529 595 → 520

The issue's upper bounds were −1.9 / −1.8 ms for the five subsystems and −3.1 / −3.6 ms for loading and registering the OAuth flows, measured against 1.10.9-rc.4. Here the setup the five subsystems no longer do at startup is 3.6 to 4.0 ms, and the OAuth flows' 1.5 ms, while registering them costs what it did. Part of what leaves "every SDK module" is a one-time cost that moves rather than goes away: the first @Tool decorated class pays for compiling the tool metadata schema, which was the job-management tools' and is now the server's first tool's. Run B ran at a high load and not interleaved, so treat its differences as estimates; the module counts are exact.

The branch will be measured again against main (91531eb, with #1011 and #1010) once it is merged in.

User-facing change

  • What changed: no export, option, default, error or flow name. The root exports of @frontmcp/sdk are the same 924 names under require() and import, each of the same kind, and dist/index.d.ts exports the same 1,852 names. Three things can be observed:
    • Under require(), reading one of the 80 root exports of jobs, workflows, channels, remote MCP or the ESM loader (JobRegistry, McpClientService, EsmModuleLoader, …) for the first time sets up its subsystem then, not when the SDK loads.
    • A server whose auth runs LocalPrimaryAuth (public, static and orchestrated modes) loads each OAuth flow's class with the first request to its route. Hooks on those flows run as before. If a class fails to load, requests to its route get a 500 instead of the server failing to start.
    • @frontmcp/sdk's CommonJS file is 18 KB larger (2,036,345 → 2,054,449 bytes): the export objects esbuild builds for the five subsystem barrels it now require()s.
  • Before → after: see above. Browser bundles, measured on today's main (998ca63, with the M10–M21 feature stack) and on this branch merged with it: create() + one tool 1,339,102 → 1,339,520 bytes minified (+418), with WebMcpPlugin 1,346,279 → 1,346,697 (+418); Vite's initial chunk is unchanged at 964,258 (the OAuth flows become separate chunks). Both stay within the current budgets, so no budget changes.
  • frontmcp.dev pages affected (if known): none known.
  • Skills catalog entries updated: none; no entry describes when the SDK's subsystems or OAuth flows load.

Tests

  • Pinned first, in their own commit, against the unchanged build: apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts with fixtures/sdk-root-exports.json, the 924 root export names and the kind of each (class, function, object, array, string, number, symbol) as require() of dist/index.js and import of dist/esm/index.mjs see them. A third case imports dist/index.js from ESM, so Node's export detection must still list every name: an ESM import saw 845 names while src/index.ts re-exported ./subsystems with export *, which is why the root names each export.
  • The root's types were compared by hand with the TypeScript checker: dist/index.d.ts exports the same 1,852 names before and after, each a value or a type as before and declared in the same file.
  • New e2e, red before the change: sdk-lazy-subsystems.e2e.spec.ts (with helpers/initialized-modules.cjs, which loads dist/index.js with esbuild's __esm helper wrapped to record the source modules it sets up). Loading the CommonJS build sets up none of the modules of the five subsystems besides the five startup helpers named above; reading JobRegistry, WorkflowRegistry, ChannelRegistry, McpClientService or EsmModuleLoader sets up its module; a public-mode server built with createFetchHandler() sets up none of the 8 OAuth flow modules, and a request to /oauth/authorize then to /oauth/token sets up only that flow's module each time.
  • New e2e: sdk-subsystem-hooks.e2e.spec.ts (with helpers/subsystem-hooks.mjs). Under require() of dist/index.js, a server-level plugin's hooks see a job run (jobs:execute-job), a workflow step, a channel notification (channels:send-notification) and the channel list (channels:list), a remote app's tool and an ESM package's tool (tools:call-tool, against a second FrontMCP server over HTTP and a local package registry), and a request to each of the 8 OAuth routes of a public-mode server (Will('checkIpFilter') on each oauth:* flow). Passes on the base build and on this branch.
  • New unit tests: src/__tests__/lazy-subsystems.spec.ts (the CommonJS variant exports the same names and the same values as subsystems.ts, and loads a subsystem only when one of its names is read), src/flows/__tests__/deferred-flow.spec.ts (a deferred flow is registered without loading its class; it loads once for concurrent runs, runs its stages and a plugin's hook, validates input with the loaded class's schema, stays unloaded for another route or method, loads for a request to its route, runs each route guard once per request, and answers a request to its route with a 500, through the fetch handler and through the Express middleware, when its class fails to load; 3 of its cases failed without the FlowInstance change, the guard case failed before the fix, and the two load-failure cases failed before the last commit, the load error escaping both handlers), src/auth/flows/__tests__/oauth-flows.spec.ts (each declaration has the name, method and path of the class it loads, in the old route order, and a public-mode server runs a plugin's hook on all 8 flows).
  • After merging main at 998ca63: nx run sdk:test passes 9,257 tests in 660 suites, and bundle-size passes 23. The pinned root exports were regenerated from main's own build (1,042 names, 76 new, none removed, no kind changed); this branch's CommonJS and ESM builds export exactly those. main added JobRunResource and CancelJobTool to the job exports, so they go through ./subsystems too.
  • On the final commit before that merge: nx run sdk:test (--maxWorkers=50%) passes 7,514 tests in 582 suites, and nx run sdk:lint reports 0 errors and no warning in a file this PR adds. node --test scripts/*.test.mjs passes 84, among them the Worker export-condition and browser build guards. e2e: bundle-size 23 (with the three new specs, 6 tests) and its typecheck-e2e, dts-consumer 11, cloudflare 16 (5 skipped), local-auth 73, orchestrated 45, hooks 23, jobs 43, channels 44 and remote 31 pass. public passes 11 of 12: should connect without authorization header can't start its server because the IDE on this machine listens on 127.0.0.1:50000 to 50002, the first ports of that suite's range (EADDRINUSE).
  • node scripts/fix-unused-imports.mjs changes nothing, and Prettier passes on every changed file. tsc --noEmit -p libs/sdk/tsconfig.spec.json reports no error. The build type-checks the library code.

Summary by CodeRabbit

  • New Features
    • SDK subsystem modules now load when their exports are first accessed, while existing root-level exports remain available.
    • OAuth flows now load when a matching request is handled rather than during initial registration.
    • CommonJS and ESM builds retain consistent SDK root exports and subsystem behavior.

…dashboard and edge instead of bundling it

The three packages bundled @frontmcp/utils, so their builds imported utils'
own dependencies (@noble/ciphers, @noble/hashes, @enclave-vm/ast,
@upstash/redis, @vercel/kv, ioredis, zod, @frontmcp/lazy-zod) without
declaring them, and loaded a second copy of utils next to the SDK's.

- add @frontmcp/utils to the external list of the CommonJS and ESM builds
- declare @frontmcp/utils at the internal version in plugin-dashboard and edge
- drop the @enclave-vm/ast dependency added in #953; utils declares it
- add demo-e2e-published-imports: packs each package, fails on bundled utils
  code or an undeclared bare import, and loads all three under require and
  import in a fresh npm project with @enclave-vm/ast 2.16.0 at the top level

Closes #956
Status, content type and body of the answers to JSON, form and other
bodies (charsets, encodings, malformed JSON, limits), and the error a later
error handler receives, before the JSON parser changes.
…ncoding

body-parser's json() decodes UTF-8 through iconv-lite, whose first decoder
loads all of its encodings, about 400 of them, on a server's first JSON
body. The Express host now reads application/json bodies with no charset
or charset=utf-8 through body-parser's raw(), so limits, inflation and read
errors stay its own, then decodes and parses them as json() does: byte
order mark dropped, empty body as {}, the same strict-mode and syntax
errors. Bodies in any other charset still go through json().

Refs #951
The SDK's ESM bundle imported @frontmcp/protocol/client for the direct
client and remote apps, so an ESM process loaded the MCP SDK's client and
its transports at startup. Under require() that subpath was already lazy.

Both now take the client from #protocol-client. Its import variant, which
the ESM build resolves, is a dynamic import() of @frontmcp/protocol/client;
everywhere else it is the static module, so the CommonJS (kept external,
lazy under require()), browser and Worker builds load as before.

Refs #951
The MCP SDK's Server builds an Ajv instance with ajv-formats in its
constructor, to check the results of the elicitations it requests itself.
FrontMCP never calls it for that, yet built one per session (per request on
a stateless web-standard server), about 2.5 ms on a server's first request.

Every MCP server FrontMCP creates now comes from createMcpServer(), whose
validator builds the same AjvJsonSchemaValidator on its first use, so a
server that does validate gets the same answers. @frontmcp/protocol exports
AjvJsonSchemaValidator for it.

Refs #951
… scan and time out npm child processes

The scan reported any function or class named like a @frontmcp/utils export, so a local helper
such as escapeHtml failed it. Names the package's own sources define are now left out, and a
positive control checks that the remaining names still identify definitions in utils' build.

npm install and the load checks run synchronously, which blocks Jest's own timeouts; they now
time out on their own.
When a host hands over a request whose body was already read into a
Buffer, body-parser's raw() skips it and json() leaves it as it is. The
UTF-8 parser parsed that Buffer anyway; it now parses only a body its own
raw() read.

Refs #951
…tens

The transport, and through it @hono/node-server and Node's bundled undici,
loaded with the first Streamable HTTP session, so a server's first
initialize waited for it. A scope that serves Streamable HTTP (streamable,
stateless or JSON responses) now loads it right after the server starts
listening, which is not on the path to listening and is usually before a
client arrives. A stdio server, a serverless handler and a Worker never
start listening and load it as before; an SSE-only server still never
loads it.

Refs #951
… server comes from

`AjvJsonSchemaValidator` came straight from the MCP SDK while `McpServer` comes
through `#mcp-server`, which resolves to the SDK's CommonJS build under `import`.
An ESM server therefore loaded the validator module from both builds. Both now
come from `#mcp-server`.
… transport early

The body pins add brotli, two byte order marks, a parameter other than charset
and a vendor +json type; they pass with and without the UTF-8 JSON parser.
A listening server loads the Streamable HTTP transport after start() returns
and reports ready; createHandler(), createFetchHandler() and runStdio() never do.
… on first use, with their hooks still running
…oader when a CommonJS process first reads their exports
…, so an ESM import of that build still sees them
…ad with a 500

canActivate awaited the class load outside the Express middleware's error
handling and before the Web fetch handler's, so a load that failed escaped as
an unhandled rejection (Express 4) or a thrown fetch handler. The flow now
claims its route, and its run throws the load error, which both adapters
answer with a 500.
…systems

# Conflicts:
#	apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs
#	apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts
#	libs/sdk/project.json
#	libs/sdk/src/remote-mcp/mcp-client.service.ts
#	libs/sdk/src/transport/web-standard-mcp.ts
Regenerated from main's own build (91531eb): 42 names added by the feature stack, none removed, no kind changed. The merged build exports the same 966 names and kinds under require() and import.
@frontegg-david
frontegg-david marked this pull request as ready for review November 2, 2026 18:30
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Walkthrough

The SDK CommonJS entry routes subsystem exports through lazy getters, and its build uses a dedicated esbuild configuration. Flow registration supports deferred class loading, including OAuth flows. Unit and end-to-end tests check exports, module initialization, route handling, and subsystem hooks.

Changes: SDK Loading

Layer / File(s) Summary
CommonJS subsystem exports
libs/sdk/src/index.ts, libs/sdk/src/subsystems.ts, libs/sdk/src/lazy-subsystems.ts, libs/sdk/esbuild.cjs.config.js, libs/sdk/project.json, libs/sdk/src/__tests__/lazy-subsystems.spec.ts, apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json, apps/e2e/demo-e2e-bundle-size/e2e/helpers/root-exports.mjs, apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts
The SDK routes subsystem values through subsystems.ts; the CommonJS build redirects those exports to lazy getters. The build configuration is moved to a dedicated file. Tests compare root exports and inspect module loading.
Deferred flow loading
libs/sdk/src/flows/deferred-flow.ts, libs/sdk/src/flows/flow.instance.ts, libs/sdk/src/flows/__tests__/deferred-flow.spec.ts
Flow registrations can hold an asynchronous class loader. FlowInstance caches the load, initializes metadata, and loads a matching class before running it. Tests cover concurrent runs, guards, validation, route matching, and load failures.
Deferred OAuth registration
libs/sdk/src/auth/flows/oauth-flows.ts, libs/sdk/src/auth/flows/oauth.*.flow.ts, libs/sdk/src/auth/instances/instance.local-primary-auth.ts, libs/sdk/src/auth/flows/__tests__/oauth-flows.spec.ts
The eight OAuth flow classes gain named exports and are registered through deferred declarations. Local primary auth registers the collection. Tests check route metadata and flow hooks.
Bundle initialization and subsystem hooks
apps/e2e/demo-e2e-bundle-size/e2e/helpers/initialized-modules.cjs, apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs, apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts, apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts
End-to-end tests inspect module initialization after SDK loading, export reads, and OAuth requests. They also check recorded job, channel, remote-tool, ESM-tool, and OAuth flow hooks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LocalPrimaryAuth
  participant FlowInstance
  participant HTTPRequest
  participant OAuthFlowModule
  LocalPrimaryAuth->>FlowInstance: register deferred OAuth flow
  HTTPRequest->>FlowInstance: match route and method
  FlowInstance->>OAuthFlowModule: dynamically import on first match
  OAuthFlowModule-->>FlowInstance: provide flow class and metadata
  FlowInstance-->>HTTPRequest: run loaded flow
Loading

Possibly related issues

  • agentfront/frontmcp#948 — The change defers setup of several SDK subsystems and OAuth flow modules, which relates to the issue’s objective of reducing startup heap use.

Suggested reviewers: alexmercerpo

Merge Risk: Low · up to dae75

The change is mergeable with awareness that e2e checks may flake or stall and that failed deferred OAuth loads remain cached.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title "perf(sdk): set up jobs, workflows, channels, remote MCP, the ESM loader and OAuth flows on first use" matches the main change. The changeset makes the CommonJS SDK root load jobs, workflows…
Description check Passed The description follows the repository template. It has the "What and why" section, with the issue reference (Closes #950) and a detailed rationale. It has the "User-facing change" section with what c…
Linked Issues check Passed Issue #950 coding requirements are met. src/index.ts keeps the root export names, and the CommonJS build swaps only ./subsystems to lazy-subsystems.ts; the reported root-export and lazy-loading …
Out of Scope Changes check Passed The changed files stay within issue #950. The lazy export module, subsystem barrel changes, and CommonJS esbuild plugin implement deferred subsystem setup. The deferred-flow implementation, OAuth decl…
Full details: Title check

Explanation

The title "perf(sdk): set up jobs, workflows, channels, remote MCP, the ESM loader and OAuth flows on first use" matches the main change. The changeset makes the CommonJS SDK root load jobs, workflows, channels, remote MCP, and the ESM loader on first read, and registers the eight OAuth flows at startup with each class loaded on its first matching request. The title is specific and accurate. It is somewhat long at 100 characters, but it stays on topic.

Full details: Description check

Explanation

The description follows the repository template. It has the "What and why" section, with the issue reference (Closes #950) and a detailed rationale. It has the "User-facing change" section with what changed, before/after, frontmcp.dev pages (none known), and skills catalog entries (none). It has the "Tests" section with extensive detail on the new e2e, unit, and regression tests. The description is thorough and covers the required information. The template's sub-bullets are all filled in.

Full details: Linked Issues check

Explanation

Issue #950 coding requirements are met. src/index.ts keeps the root export names, and the CommonJS build swaps only ./subsystems to lazy-subsystems.ts; the reported root-export and lazy-loading tests verify names, kinds, values, and deferred setup. oauthFlows registers all eight flows by name and route. FlowInstance loads a deferred class once before guards and execution, so flow hooks remain active, including public-mode OAuth hooks. The reported subsystem-hook and deferred-flow tests cover this behavior. The swap is limited to the CommonJS build, while ESM, browser, and Worker builds retain the static module. The reported bundle-size and Worker export-condition tests pass. The PR also reports benchmark measurements for cold start and RSS, with cold-start separation identified as inconclusive under load.

Full details: Out of Scope Changes check

Explanation

The changed files stay within issue #950. The lazy export module, subsystem barrel changes, and CommonJS esbuild plugin implement deferred subsystem setup. The deferred-flow implementation, OAuth declarations, named exports, and FlowInstance changes implement deferred OAuth loading and preserve hooks and route behavior. The per-request guard-list change fixes behavior in the same flow path used by deferred flows. Tests, fixtures, and build configuration directly verify or enable these objectives. The PR leaves the unrelated subpath-import and broken subpath-export issues unchanged.

Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 4 functions across 25 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 1a4d98a and dae7555.

Files selected for processing: 4
  • apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json
  • libs/sdk/src/index.ts
  • libs/sdk/src/lazy-subsystems.ts
  • libs/sdk/src/subsystems.ts

Included review availability: This review used your included allowance. Your plan provides up to 5 included reviews per hour; 1 remain after this review.

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Fix comments: Fix CodeRabbit comments on this PR

Comment thread libs/sdk/src/auth/flows/oauth-flows.ts Resolved
Comment thread libs/sdk/src/flows/flow.instance.ts Resolved
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Performance Test Results

Status: ✅ All tests passed

Summary

Project Tests Passed Warnings Failed Leaks
✅ demo-e2e-agents 4 4 0 0 0
✅ demo-e2e-cache 11 11 0 0 0
✅ demo-e2e-codecall 4 4 0 0 0
✅ demo-e2e-config 4 4 0 0 0
✅ demo-e2e-direct 3 3 0 0 0
✅ demo-e2e-elicitation 1 1 0 0 0
✅ demo-e2e-errors 4 4 0 0 0
✅ demo-e2e-hooks 3 3 0 0 0
✅ demo-e2e-multiapp 4 4 0 0 0
✅ demo-e2e-notifications 3 3 0 0 0
✅ demo-e2e-openapi 2 2 0 0 0
✅ demo-e2e-providers 4 4 0 0 0
✅ demo-e2e-public 4 4 0 0 0
✅ demo-e2e-redis 15 15 0 0 0
✅ demo-e2e-remember 4 4 0 0 0
✅ demo-e2e-remote 5 5 0 0 0
✅ demo-e2e-serverless 2 2 0 0 0
✅ demo-e2e-skills 15 15 0 0 0
✅ demo-e2e-standalone 2 2 0 0 0
✅ demo-e2e-transport-recreation 3 3 0 0 0
✅ demo-e2e-ui 4 4 0 0 0

Total: 101 tests across 21 projects

📊 View full report in workflow run


Generated at: 2026-11-03T07:52:30.506Z
Commit: 52f3c685

main gained JobRunResource and CancelJobTool. They go through ./subsystems
like the other job exports, so the CommonJS build still loads the job
subsystem the first time one of them is read.
Regenerated from main's own build (998ca63): 76 names added, none removed, no kind changed. This branch's CommonJS and ESM builds export exactly the same names and kinds.
frontegg-david added a commit that referenced this pull request Nov 3, 2026
… over #1016

On main 998ca63 with #1016, the lazy stand-ins add 457 bytes to create() + WebMcpPlugin + one tool (1,346,697 -> 1,347,154) and 437 to Vite's initial chunk (964,258 -> 964,695). Those budgets rise by exactly that: 1,346,921 -> 1,347,378 and 964,390 -> 964,827. create() + one tool (1,339,977) still fits 1,340,056.
@frontegg-david
frontegg-david merged commit f77953c into main Nov 3, 2026
46 checks passed
@frontegg-david
frontegg-david deleted the perf/p04-lazy-sdk-subsystems branch November 3, 2026 07:54
frontegg-david added a commit that referenced this pull request Nov 3, 2026
…1021)

* perf(lazy-zod): keep what a stand-in is built from in fields, not closures

internalZ made two closures and their contexts per stand-in, which cost more
than the stand-in itself. A stand-in now keeps zod's factory, its namespace and
the arguments (or the parent and method it was chained from) and drops the
arguments once built. The public z's stand-ins make one closure instead of two.

* fix(lazy-zod): drop a stand-in's arguments and parent only once it has built

A stand-in cleared its arguments before building, so a build that threw left
it empty: the next parse built the schema without them (an internalZ.object
whose build threw became z.object() and accepted any object). It now drops
them after a successful build, as the closure was dropped before.

A chained stand-in also kept its parent stand-in, and the parent's built
schema, for as long as it lived. It now drops the parent once built.

* test(e2e): raise two browser budgets by this branch's measured growth over #1016

On main 998ca63 with #1016, the lazy stand-ins add 457 bytes to create() + WebMcpPlugin + one tool (1,346,697 -> 1,347,154) and 437 to Vite's initial chunk (964,258 -> 964,695). Those budgets rise by exactly that: 1,346,921 -> 1,347,378 and 964,390 -> 964,827. create() + one tool (1,339,977) still fits 1,340,056.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1.10: split the SDK bundle, so a server doesn't set up subsystems it doesn't use

2 participants