perf: add preloadSchemas() and warm wire schemas eagerly on workerd (#2483)
This commit is contained in:
parent
8e1d2e92b1
commit
3f07a325c6
20 changed files with 550 additions and 14 deletions
6
.changeset/workerd-schema-preload.md
Normal file
6
.changeset/workerd-schema-preload.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@modelcontextprotocol/client': minor
|
||||
'@modelcontextprotocol/server': minor
|
||||
---
|
||||
|
||||
Add `preloadSchemas()`, an explicit opt-in to eager wire-schema construction, and call it automatically in the Cloudflare Workers builds. The wire schemas are built lazily by default, which is the right trade on process-per-invocation runtimes — but on isolate platforms that bill request CPU while module evaluation runs during isolate warm-up, laziness moves construction into the first request each fresh isolate serves. Calling `preloadSchemas()` at module scope (it is synchronous and idempotent) moves that one-time cost back to module evaluation; the packages' workerd export condition now does this automatically, while the Node and browser builds stay lazy. The server package gains a dedicated browser shim for this (its `browser` condition previously reused the workerd shim), so browser bundles keep lazy construction.
|
||||
|
|
@ -106,5 +106,11 @@ export { fromJsonSchema } from './fromJsonSchema';
|
|||
export type { InputRequiredOptions } from '@modelcontextprotocol/core-internal';
|
||||
export { withInputRequired } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
// Explicit opt-in to eager wire-schema construction, for platforms that bill
|
||||
// request CPU but not module evaluation (isolate-based edge/serverless
|
||||
// runtimes). The package's workerd build calls it automatically at module
|
||||
// scope; other builds stay lazy unless the application calls it itself.
|
||||
export { preloadSchemas } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
// re-export curated public API from core
|
||||
export * from '@modelcontextprotocol/core-internal/public';
|
||||
|
|
|
|||
|
|
@ -3,8 +3,17 @@
|
|||
*
|
||||
* This file is selected via package.json export conditions when running in workerd.
|
||||
*/
|
||||
import { preloadSchemas } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker';
|
||||
|
||||
// Platform asymmetry: isolate platforms like workerd evaluate module scope
|
||||
// during deployment/isolate warm-up, outside any request's billed CPU, while
|
||||
// lazy construction would land inside the first request each fresh isolate
|
||||
// serves. Node and browser shims stay lazy — there, module evaluation is
|
||||
// process/page startup and boot latency is the cost that matters.
|
||||
preloadSchemas();
|
||||
|
||||
/**
|
||||
* Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept —
|
||||
* in Cloudflare Workers, a `TypeError` from `fetch` is always a real network/configuration
|
||||
|
|
|
|||
90
packages/client/test/client/workerdSchemaPreload.test.ts
Normal file
90
packages/client/test/client/workerdSchemaPreload.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Platform-conditional schema warm-up pins, asserted against the real build
|
||||
* outputs.
|
||||
*
|
||||
* The wire schemas are built lazily by default — the right trade on
|
||||
* process-per-invocation runtimes, where module evaluation is boot latency.
|
||||
* On isolate platforms (workerd), module scope evaluates during isolate
|
||||
* warm-up outside any request's billed CPU, so the workerd shim calls
|
||||
* `preloadSchemas()` at module scope to keep construction out of the first
|
||||
* request. Two regressions would be silent without these pins:
|
||||
*
|
||||
* 1. The workerd condition loses its module-scope call (a shim refactor drops
|
||||
* it) — fresh isolates go back to paying schema construction inside the
|
||||
* first request's CPU.
|
||||
* 2. The node or browser condition gains one (an import shuffle re-eagerizes
|
||||
* it) — every process boot / page load pays construction for validations
|
||||
* that may never happen.
|
||||
*
|
||||
* Identity also matters: the warm-up only helps if the shim forces the SAME
|
||||
* memo module the package entry validates through. The shim entry must
|
||||
* import `preloadSchemas` from the shared chunk — a duplicated definition in
|
||||
* the shim chunk would warm a twin module and leave the real one cold.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { beforeAll, describe, expect, test } from 'vitest';
|
||||
|
||||
import { ensureBuilt } from '../helpers/ensureBuilt';
|
||||
|
||||
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const distDir = join(pkgDir, 'dist');
|
||||
|
||||
/**
|
||||
* A module-scope `preloadSchemas();` call: statement at column 0 — entry-level
|
||||
* statements are unindented in the unminified build output, while statements
|
||||
* inside function bodies are indented, so a call that merely sits in some
|
||||
* bundled helper body cannot satisfy (or trip) this pin. The CJS build calls
|
||||
* through the required chunk's namespace (`require_src.preloadSchemas();`),
|
||||
* so an optional receiver is allowed.
|
||||
*/
|
||||
const MODULE_SCOPE_PRELOAD_CALL = /^(?:[\w$]+\.)?preloadSchemas\(\);/m;
|
||||
|
||||
/**
|
||||
* An entry-level `import { …, X as preloadSchemas, … } from "./chunk"` (or
|
||||
* the un-aliased `{ preloadSchemas }` form), capturing the chunk specifier.
|
||||
*/
|
||||
const PRELOAD_IMPORT = /import\s*\{[^}]*\bpreloadSchemas\b[^}]*\}\s*from\s*"(\.\/[^"]+)"/;
|
||||
|
||||
function dist(file: string): string {
|
||||
return readFileSync(join(distDir, file), 'utf8');
|
||||
}
|
||||
|
||||
describe('workerd schema warm-up (built dist)', () => {
|
||||
beforeAll(async () => {
|
||||
await ensureBuilt(pkgDir);
|
||||
}, 180_000);
|
||||
|
||||
test('shimsWorkerd calls preloadSchemas() at module scope (ESM and CJS)', () => {
|
||||
expect(dist('shimsWorkerd.mjs')).toMatch(MODULE_SCOPE_PRELOAD_CALL);
|
||||
expect(dist('shimsWorkerd.cjs')).toMatch(MODULE_SCOPE_PRELOAD_CALL);
|
||||
});
|
||||
|
||||
test('node and browser shims stay lazy — no preloadSchemas reference at all', () => {
|
||||
for (const shim of ['shimsNode.mjs', 'shimsNode.cjs', 'shimsBrowser.mjs', 'shimsBrowser.cjs']) {
|
||||
expect(dist(shim), `${shim} must not re-eagerize schema construction`).not.toMatch(/\bpreloadSchemas\b/);
|
||||
}
|
||||
});
|
||||
|
||||
test('the root entry exports preloadSchemas but never calls it at module scope', () => {
|
||||
const index = dist('index.mjs');
|
||||
expect(index).toMatch(/\bpreloadSchemas\b/);
|
||||
expect(index).not.toMatch(MODULE_SCOPE_PRELOAD_CALL);
|
||||
});
|
||||
|
||||
test('the workerd shim warms the same chunk the root entry uses (no duplicated schema graph)', () => {
|
||||
const entries = ['index.mjs', 'shimsWorkerd.mjs', 'shimsNode.mjs', 'shimsBrowser.mjs', 'stdio.mjs'];
|
||||
for (const entry of entries) {
|
||||
expect(dist(entry), `${entry} must not carry its own preloadSchemas definition`).not.toMatch(/function preloadSchemas\b/);
|
||||
}
|
||||
|
||||
const shimSource = dist('shimsWorkerd.mjs').match(PRELOAD_IMPORT)?.[1];
|
||||
const indexSource = dist('index.mjs').match(PRELOAD_IMPORT)?.[1];
|
||||
expect(shimSource).toBeDefined();
|
||||
expect(indexSource).toBeDefined();
|
||||
expect(shimSource).toBe(indexSource);
|
||||
expect(dist(shimSource!.replace('./', ''))).toMatch(/function preloadSchemas\b/);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,7 +17,20 @@ const execFileAsync = promisify(execFile);
|
|||
const DIST_SENTINELS: Record<string, string[]> = {
|
||||
// Include late-written artifacts (dts pass, CJS validators) so an
|
||||
// interrupted build never satisfies the fast path.
|
||||
client: ['index.mjs', 'stdio.mjs', 'validators/ajv.cjs', 'index.d.mts', 'index.d.cts'],
|
||||
client: [
|
||||
'index.mjs',
|
||||
'stdio.mjs',
|
||||
// Shim entries in both formats — the workerdSchemaPreload suite reads them all.
|
||||
'shimsWorkerd.mjs',
|
||||
'shimsWorkerd.cjs',
|
||||
'shimsNode.mjs',
|
||||
'shimsNode.cjs',
|
||||
'shimsBrowser.mjs',
|
||||
'shimsBrowser.cjs',
|
||||
'validators/ajv.cjs',
|
||||
'index.d.mts',
|
||||
'index.d.cts'
|
||||
],
|
||||
core: ['index.mjs', 'internal.mjs', 'index.d.mts', 'internal.d.mts']
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ export default [
|
|||
// Wire-layer isolation, outbound direction: nothing outside src/wire/ may
|
||||
// reach into a wire revision module. The wire layer's only public surface
|
||||
// is src/wire/codec.ts (the WireCodec interface), src/wire/bootstrap.ts,
|
||||
// and the leaf result-family module src/wire/resultFamilies.ts (the shared
|
||||
// the revision-neutral warm-up entry src/wire/preload.ts, and the leaf
|
||||
// result-family module src/wire/resultFamilies.ts (the shared
|
||||
// tools/call-result ruling, re-exported on the barrel).
|
||||
// test/wire/layeringInvariants.test.ts re-derives the same invariant with
|
||||
// zero exceptions. Type-only imports are exempted at the lint layer (a
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ export * from './util/schema';
|
|||
export * from './util/standardSchema';
|
||||
export * from './util/zodCompat';
|
||||
export { codecForVersion, MODERN_WIRE_REVISION } from './wire/codec';
|
||||
// Revision-neutral warm-up entry for the lazy wire-schema layers. Exposes no
|
||||
// per-revision objects — it only forces the memos every consumer already
|
||||
// pulls through — so it stays within the no-per-revision-exports rule above.
|
||||
// Re-exported as public API by the client and server packages for platforms
|
||||
// that bill request CPU but not module evaluation.
|
||||
export { preloadSchemas } from './wire/preload';
|
||||
export { normalizeContentlessToolResult, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from './wire/resultFamilies';
|
||||
|
||||
// Validator provider classes stay subpath-only. Re-exporting them here, even as
|
||||
|
|
|
|||
57
packages/core-internal/src/wire/preload.ts
Normal file
57
packages/core-internal/src/wire/preload.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Explicit warm-up entry for the lazy wire-schema layers.
|
||||
*
|
||||
* The per-revision wire schemas are built lazily: each era's schema set sits
|
||||
* behind a memoized factory (`buildSchemas2025`/`buildSchemas2026`), and the
|
||||
* registry/codec lookup maps above those factories are memoized the same way.
|
||||
* That laziness is the right default on process-per-invocation runtimes (CLI
|
||||
* tools, dev servers), where module evaluation IS startup latency and most
|
||||
* short-lived processes never validate a message on both eras.
|
||||
*
|
||||
* On platforms that bill request CPU but not module evaluation — isolate-based
|
||||
* edge/serverless runtimes such as Cloudflare Workers — the trade inverts:
|
||||
* module-scope work runs during isolate warm-up outside any request, while
|
||||
* lazy construction lands inside the first request's billed (and latency
|
||||
* budgeted) CPU. `preloadSchemas()` lets deployments on such platforms move
|
||||
* the one-time construction cost back to module scope by calling it at module
|
||||
* scope themselves. The packages' own workerd shims already do this, so
|
||||
* Workers deployments get eager construction automatically.
|
||||
*/
|
||||
import { buildSchemas2025 } from './rev2025-11-25/buildSchemas';
|
||||
import { warmRegistryMaps2025 } from './rev2025-11-25/registry';
|
||||
import { buildSchemas2026 } from './rev2026-07-28/buildSchemas';
|
||||
import { warmWireResultSchemas2026 } from './rev2026-07-28/codec';
|
||||
import { warmInputSchemaMaps2026 } from './rev2026-07-28/inputRequired';
|
||||
|
||||
/**
|
||||
* Eagerly builds every lazily-constructed wire-schema layer, so that no later
|
||||
* validation pays schema-construction cost.
|
||||
*
|
||||
* Synchronous and idempotent: every layer is a memo, so the first call does
|
||||
* all the work and subsequent calls return immediately. Reference identity is
|
||||
* unaffected — this forces the same memos every lazy consumer pulls through.
|
||||
*
|
||||
* Call it at module scope on platforms that bill per-request CPU but not
|
||||
* module evaluation (isolate-based edge/serverless runtimes), where deferring
|
||||
* construction would move it into the first request of every fresh isolate:
|
||||
*
|
||||
* ```ts
|
||||
* // from '@modelcontextprotocol/server' or '@modelcontextprotocol/client' —
|
||||
* // each package bundles its own schema copy, so warm the one(s) you import.
|
||||
* preloadSchemas(); // module scope — runs during isolate warm-up
|
||||
* ```
|
||||
*
|
||||
* On Node CLIs and other process-per-invocation runtimes, prefer the lazy
|
||||
* default — there, module-scope construction is pure added boot latency.
|
||||
*/
|
||||
export function preloadSchemas(): void {
|
||||
// The era schema factories — the bulk of the construction cost.
|
||||
buildSchemas2025();
|
||||
buildSchemas2026();
|
||||
// The memoized lookup layers above the factories. (The 2026 registry has
|
||||
// no map memo of its own — it reads the dispatch maps straight off the
|
||||
// built schema set.)
|
||||
warmRegistryMaps2025();
|
||||
warmInputSchemaMaps2026();
|
||||
warmWireResultSchemas2026();
|
||||
}
|
||||
|
|
@ -212,6 +212,14 @@ function registryMaps(): RegistryMaps {
|
|||
return maps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the lazy registry maps (and, through them, the era's schema memo).
|
||||
* Warm-up hook for `preloadSchemas()` — no-op once the maps exist.
|
||||
*/
|
||||
export function warmRegistryMaps2025(): void {
|
||||
registryMaps();
|
||||
}
|
||||
|
||||
/** The 2025-era request-method set (registry membership = the deletion story). */
|
||||
export function hasRequestMethod2025(method: string): method is Rev2025RequestMethod {
|
||||
return Object.prototype.hasOwnProperty.call(requestMethodKeys, method);
|
||||
|
|
|
|||
|
|
@ -298,3 +298,11 @@ function getWireResultSchemas(): Record<string, z.ZodType> {
|
|||
};
|
||||
return wireResultSchemasMemo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the lazy wire-result wrapper map (and, through it, the era's schema
|
||||
* memo). Warm-up hook for `preloadSchemas()` — no-op once the map exists.
|
||||
*/
|
||||
export function warmWireResultSchemas2026(): void {
|
||||
getWireResultSchemas();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@ function inputSchemaMaps(): InputSchemaMaps {
|
|||
return maps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the lazy embedded-request maps (and, through them, the era's schema
|
||||
* memo). Warm-up hook for `preloadSchemas()` — no-op once the maps exist.
|
||||
*/
|
||||
export function warmInputSchemaMaps2026(): void {
|
||||
inputSchemaMaps();
|
||||
}
|
||||
|
||||
export function isInputRequestMethod2026(method: string): method is InputRequestMethod2026 {
|
||||
return (INPUT_REQUEST_METHODS_2026 as readonly string[]).includes(method);
|
||||
}
|
||||
|
|
|
|||
84
packages/core-internal/test/wire/preload.test.ts
Normal file
84
packages/core-internal/test/wire/preload.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* preloadSchemas(): the explicit warm-up entry for the lazy wire-schema
|
||||
* layers (era factories + the memoized registry/codec lookup maps).
|
||||
*
|
||||
* The contract under test:
|
||||
* - synchronous and idempotent — repeated calls keep returning the same
|
||||
* memoized objects;
|
||||
* - it warms the SAME memos every lazy consumer pulls through, so lookups
|
||||
* after a preload serve reference-identical objects (no second
|
||||
* construction, no parallel schema graph);
|
||||
* - validation on both eras works normally after a preload.
|
||||
*
|
||||
* This file deliberately imports no eager schema shim (the per-era
|
||||
* `schemas.ts` re-export surfaces): vitest isolates module registries per
|
||||
* test file, so preloadSchemas() is the only thing that warms the memos here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { codecForVersion, MODERN_WIRE_REVISION } from '../../src/wire/codec';
|
||||
import { preloadSchemas } from '../../src/wire/preload';
|
||||
import { buildSchemas2025 } from '../../src/wire/rev2025-11-25/buildSchemas';
|
||||
import { getNotificationSchema, getRequestSchema } from '../../src/wire/rev2025-11-25/registry';
|
||||
import { buildSchemas2026 } from '../../src/wire/rev2026-07-28/buildSchemas';
|
||||
import { getInputRequestSchema2026 } from '../../src/wire/rev2026-07-28/inputRequired';
|
||||
import { getRequestSchema2026 } from '../../src/wire/rev2026-07-28/registry';
|
||||
|
||||
// Module scope, mirroring the intended call site on isolate platforms.
|
||||
preloadSchemas();
|
||||
|
||||
describe('preloadSchemas', () => {
|
||||
it('returns void, synchronously', () => {
|
||||
expect(preloadSchemas()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is idempotent: repeated calls keep serving the same memoized objects', () => {
|
||||
const s2025 = buildSchemas2025();
|
||||
const s2026 = buildSchemas2026();
|
||||
const pingSchema = getRequestSchema('ping');
|
||||
const rootsInput = getInputRequestSchema2026('roots/list');
|
||||
|
||||
preloadSchemas();
|
||||
preloadSchemas();
|
||||
|
||||
expect(buildSchemas2025()).toBe(s2025);
|
||||
expect(buildSchemas2026()).toBe(s2026);
|
||||
expect(getRequestSchema('ping')).toBe(pingSchema);
|
||||
expect(getInputRequestSchema2026('roots/list')).toBe(rootsInput);
|
||||
});
|
||||
|
||||
it('warms the same memos the lazy consumers pull through (reference identity, no parallel graph)', () => {
|
||||
// 2025 era: registry lookups serve objects out of the preloaded set.
|
||||
const s2025 = buildSchemas2025();
|
||||
expect(getRequestSchema('ping')).toBe(s2025.PingRequestSchema);
|
||||
expect(getRequestSchema('initialize')).toBe(s2025.InitializeRequestSchema);
|
||||
expect(getNotificationSchema('notifications/progress')).toBe(s2025.ProgressNotificationSchema);
|
||||
|
||||
// 2026 era: the registry reads the dispatch maps straight off the
|
||||
// preloaded set, and the in-band response map serves the same objects.
|
||||
const s2026 = buildSchemas2026();
|
||||
expect(getRequestSchema2026('tools/list')).toBe(s2026.dispatchRequestSchemas['tools/list']);
|
||||
expect(getInputRequestSchema2026('roots/list')).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves validation working normally on both eras', () => {
|
||||
const legacy = codecForVersion(undefined);
|
||||
expect(legacy.era).toBe('2025-11-25');
|
||||
expect(legacy.validateRequest('ping', { method: 'ping' })).toEqual({ ok: true, value: { method: 'ping' } });
|
||||
expect(legacy.validateNotification('notifications/initialized', { method: 'notifications/initialized' })).toMatchObject({
|
||||
ok: true
|
||||
});
|
||||
|
||||
const modern = codecForVersion(MODERN_WIRE_REVISION);
|
||||
expect(modern.era).toBe('2026-07-28');
|
||||
expect(modern.validateRequest('tools/list', { method: 'tools/list' })).toMatchObject({ ok: true });
|
||||
// Warmed wire-result wrappers: decode step 2 parses against the
|
||||
// preloaded map (a shape violation still fails cleanly).
|
||||
expect(modern.decodeResult('tools/list', { resultType: 'complete', ttlMs: 0, cacheScope: 'private', tools: [] })).toMatchObject({
|
||||
kind: 'complete'
|
||||
});
|
||||
expect(
|
||||
modern.decodeResult('tools/list', { resultType: 'complete', ttlMs: 0, cacheScope: 'private', tools: 'not-an-array' })
|
||||
).toMatchObject({ kind: 'invalid' });
|
||||
});
|
||||
});
|
||||
|
|
@ -73,12 +73,12 @@
|
|||
},
|
||||
"browser": {
|
||||
"import": {
|
||||
"types": "./dist/shimsWorkerd.d.mts",
|
||||
"default": "./dist/shimsWorkerd.mjs"
|
||||
"types": "./dist/shimsBrowser.d.mts",
|
||||
"default": "./dist/shimsBrowser.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/shimsWorkerd.d.cts",
|
||||
"default": "./dist/shimsWorkerd.cjs"
|
||||
"types": "./dist/shimsBrowser.d.cts",
|
||||
"default": "./dist/shimsBrowser.cjs"
|
||||
}
|
||||
},
|
||||
"node": {
|
||||
|
|
|
|||
|
|
@ -99,5 +99,11 @@ export type { CacheHint, CacheScope } from '@modelcontextprotocol/core-internal'
|
|||
export type { ElicitInputParams, InputRequiredSpec, InputResponseView } from '@modelcontextprotocol/core-internal';
|
||||
export { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
// Explicit opt-in to eager wire-schema construction, for platforms that bill
|
||||
// request CPU but not module evaluation (isolate-based edge/serverless
|
||||
// runtimes). The package's workerd build calls it automatically at module
|
||||
// scope; other builds stay lazy unless the application calls it itself.
|
||||
export { preloadSchemas } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
// re-export curated public API from core
|
||||
export * from '@modelcontextprotocol/core-internal/public';
|
||||
|
|
|
|||
27
packages/server/src/shimsBrowser.ts
Normal file
27
packages/server/src/shimsBrowser.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Browser runtime shims for server package
|
||||
*
|
||||
* This file is selected via package.json export conditions when bundling for
|
||||
* browsers. It binds the same platform choices as the workerd shim (the
|
||||
* cfWorker validator, the process stub) WITHOUT the module-scope
|
||||
* `preloadSchemas()` call: in a browser, module evaluation is page load —
|
||||
* boot latency — so schema construction stays lazy, exactly like Node.
|
||||
*/
|
||||
export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker';
|
||||
|
||||
/**
|
||||
* Stub process object for non-Node.js environments.
|
||||
* StdioServerTransport is not supported in Cloudflare Workers/browser environments.
|
||||
*/
|
||||
function notSupported(): never {
|
||||
throw new Error('StdioServerTransport is not supported in this environment. Use StreamableHTTPServerTransport instead.');
|
||||
}
|
||||
|
||||
export const process = {
|
||||
get stdin(): never {
|
||||
return notSupported();
|
||||
},
|
||||
get stdout(): never {
|
||||
return notSupported();
|
||||
}
|
||||
};
|
||||
|
|
@ -3,8 +3,17 @@
|
|||
*
|
||||
* This file is selected via package.json export conditions when running in workerd.
|
||||
*/
|
||||
import { preloadSchemas } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker';
|
||||
|
||||
// Platform asymmetry: isolate platforms like workerd evaluate module scope
|
||||
// during deployment/isolate warm-up, outside any request's billed CPU, while
|
||||
// lazy construction would land inside the first request each fresh isolate
|
||||
// serves. The Node and browser shims stay lazy — there, module evaluation is
|
||||
// process/page startup and boot latency is the cost that matters.
|
||||
preloadSchemas();
|
||||
|
||||
/**
|
||||
* Stub process object for non-Node.js environments.
|
||||
* StdioServerTransport is not supported in Cloudflare Workers/browser environments.
|
||||
|
|
|
|||
105
packages/server/test/helpers/ensureBuilt.ts
Normal file
105
packages/server/test/helpers/ensureBuilt.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Server-package copy of packages/client/test/helpers/ensureBuilt.ts (per-package
|
||||
* test dirs stay self-contained). Keep the locking logic in sync with that file.
|
||||
*/
|
||||
import { execFile } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Canonical dist sentinels per package. Every caller waits on the same
|
||||
* complete set: if callers checked their own subsets, a partial dist (from an
|
||||
* interrupted build) could satisfy one caller's fast path while another still
|
||||
* rebuilds — and the rebuild's clean step would wipe files under the first
|
||||
* caller's running tests, which is exactly the race this helper exists to
|
||||
* prevent.
|
||||
*/
|
||||
const DIST_SENTINELS: Record<string, string[]> = {
|
||||
server: [
|
||||
'index.mjs',
|
||||
'stdio.mjs',
|
||||
'shimsWorkerd.mjs',
|
||||
'shimsWorkerd.cjs',
|
||||
'shimsNode.mjs',
|
||||
'shimsNode.cjs',
|
||||
'shimsBrowser.mjs',
|
||||
'shimsBrowser.cjs'
|
||||
]
|
||||
};
|
||||
|
||||
/** The build is killed after this long; execFile's kill still runs our finally. */
|
||||
const BUILD_TIMEOUT_MS = 60_000;
|
||||
/** How long a waiter polls for another worker's build — outlasts BUILD_TIMEOUT_MS. */
|
||||
const WAIT_DEADLINE_MS = 90_000;
|
||||
/** A lock older than this belongs to a dead worker (finally never ran) — steal it. */
|
||||
const STALE_LOCK_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Build a package's dist on demand, safely across parallel vitest workers.
|
||||
*
|
||||
* Vitest runs test files in separate worker processes, so two files that each
|
||||
* "build if dist is missing" can race on a cold checkout: both see no dist,
|
||||
* both spawn `pnpm build`, and tsdown's clean step deletes files out from
|
||||
* under whichever worker is already reading them. This helper makes the build
|
||||
* single-flight with an atomic mkdir lock: the first worker builds, everyone
|
||||
* else waits for the sentinel files to appear and the lock to clear, and once
|
||||
* the dist exists nobody ever rebuilds (so no later clean can wipe it
|
||||
* mid-read).
|
||||
*/
|
||||
export async function ensureBuilt(pkgDir: string): Promise<void> {
|
||||
const sentinels = DIST_SENTINELS[basename(pkgDir)];
|
||||
if (!sentinels) {
|
||||
throw new Error(`No dist sentinels registered for ${pkgDir} — add the package to DIST_SENTINELS`);
|
||||
}
|
||||
const lockDir = join(pkgDir, '.dist-build-lock');
|
||||
const haveAll = () => sentinels.every(s => existsSync(join(pkgDir, 'dist', s)));
|
||||
const deadline = Date.now() + WAIT_DEADLINE_MS;
|
||||
for (;;) {
|
||||
if (haveAll() && !existsSync(lockDir)) return;
|
||||
try {
|
||||
mkdirSync(lockDir);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||
// Another worker holds the lock. If the holder died without its
|
||||
// finally running (SIGKILL/OOM), the lock never clears — steal it
|
||||
// once it is older than any live build could be.
|
||||
try {
|
||||
if (Date.now() - statSync(lockDir).mtimeMs > STALE_LOCK_MS) {
|
||||
rmSync(lockDir, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue; // lock vanished between mkdir and stat — re-check now
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`Timed out waiting for ${pkgDir}/dist (${sentinels.join(', ')}) ` +
|
||||
`while another worker held the build lock at ${lockDir}`
|
||||
);
|
||||
}
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (!haveAll()) {
|
||||
try {
|
||||
await execFileAsync('pnpm', ['build'], {
|
||||
cwd: pkgDir,
|
||||
timeout: BUILD_TIMEOUT_MS,
|
||||
maxBuffer: 16 * 1024 * 1024
|
||||
});
|
||||
} catch (err) {
|
||||
const stderr = (err as { stderr?: string }).stderr ?? '';
|
||||
throw new Error(`pnpm build failed in ${pkgDir}: ${(err as Error).message}\n${stderr.slice(-2000)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
rmSync(lockDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { beforeAll, describe, expect, test } from 'vitest';
|
||||
|
||||
import { ensureBuilt } from '../helpers/ensureBuilt';
|
||||
|
||||
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const distDir = join(pkgDir, 'dist');
|
||||
const requireDist = createRequire(join(pkgDir, 'package.json'));
|
||||
|
|
@ -37,11 +38,12 @@ function rootExportBlockOf(content: string): string {
|
|||
}
|
||||
|
||||
describe('@modelcontextprotocol/server root entry is browser-safe', () => {
|
||||
beforeAll(() => {
|
||||
if (!existsSync(join(distDir, 'index.mjs')) || !existsSync(join(distDir, 'stdio.mjs'))) {
|
||||
execFileSync('pnpm', ['build'], { cwd: pkgDir, stdio: 'inherit' });
|
||||
}
|
||||
}, 60_000);
|
||||
beforeAll(async () => {
|
||||
// Single-flight across parallel vitest workers: this file and
|
||||
// workerdSchemaPreload.test.ts both read the built dist, and two
|
||||
// unlocked `pnpm build`s would race tsdown's clean step.
|
||||
await ensureBuilt(pkgDir);
|
||||
}, 180_000);
|
||||
|
||||
test('dist/index.mjs does not export StdioServerTransport and has no process-stdio runtime imports', () => {
|
||||
const entry = readFileSync(join(distDir, 'index.mjs'), 'utf8');
|
||||
|
|
@ -66,7 +68,7 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => {
|
|||
});
|
||||
|
||||
test('runtime shims vendor default validator backends instead of requiring consumers to install them', () => {
|
||||
for (const shim of ['shimsNode.mjs', 'shimsWorkerd.mjs']) {
|
||||
for (const shim of ['shimsNode.mjs', 'shimsWorkerd.mjs', 'shimsBrowser.mjs']) {
|
||||
const entry = join(distDir, shim);
|
||||
expect(readFileSync(entry, 'utf8')).not.toMatch(VALIDATOR_BACKEND_IMPORT);
|
||||
|
||||
|
|
|
|||
90
packages/server/test/server/workerdSchemaPreload.test.ts
Normal file
90
packages/server/test/server/workerdSchemaPreload.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Platform-conditional schema warm-up pins, asserted against the real build
|
||||
* outputs.
|
||||
*
|
||||
* The wire schemas are built lazily by default — the right trade on
|
||||
* process-per-invocation runtimes, where module evaluation is boot latency.
|
||||
* On isolate platforms (workerd), module scope evaluates during isolate
|
||||
* warm-up outside any request's billed CPU, so the workerd shim calls
|
||||
* `preloadSchemas()` at module scope to keep construction out of the first
|
||||
* request. Two regressions would be silent without these pins:
|
||||
*
|
||||
* 1. The workerd condition loses its module-scope call (a shim refactor drops
|
||||
* it) — fresh isolates go back to paying schema construction inside the
|
||||
* first request's CPU.
|
||||
* 2. The node or browser condition gains one (an import shuffle re-eagerizes
|
||||
* it) — every process boot / page load pays construction for validations
|
||||
* that may never happen.
|
||||
*
|
||||
* Identity also matters: the warm-up only helps if the shim forces the SAME
|
||||
* memo module the package entry validates through. The shim entry must
|
||||
* import `preloadSchemas` from the shared chunk — a duplicated definition in
|
||||
* the shim chunk would warm a twin module and leave the real one cold.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { beforeAll, describe, expect, test } from 'vitest';
|
||||
|
||||
import { ensureBuilt } from '../helpers/ensureBuilt';
|
||||
|
||||
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const distDir = join(pkgDir, 'dist');
|
||||
|
||||
/**
|
||||
* A module-scope `preloadSchemas();` call: statement at column 0 — entry-level
|
||||
* statements are unindented in the unminified build output, while statements
|
||||
* inside function bodies are indented, so a call that merely sits in some
|
||||
* bundled helper body cannot satisfy (or trip) this pin. The CJS build calls
|
||||
* through the required chunk's namespace (`require_src.preloadSchemas();`),
|
||||
* so an optional receiver is allowed.
|
||||
*/
|
||||
const MODULE_SCOPE_PRELOAD_CALL = /^(?:[\w$]+\.)?preloadSchemas\(\);/m;
|
||||
|
||||
/**
|
||||
* An entry-level `import { …, X as preloadSchemas, … } from "./chunk"` (or
|
||||
* the un-aliased `{ preloadSchemas }` form), capturing the chunk specifier.
|
||||
*/
|
||||
const PRELOAD_IMPORT = /import\s*\{[^}]*\bpreloadSchemas\b[^}]*\}\s*from\s*"(\.\/[^"]+)"/;
|
||||
|
||||
function dist(file: string): string {
|
||||
return readFileSync(join(distDir, file), 'utf8');
|
||||
}
|
||||
|
||||
describe('workerd schema warm-up (built dist)', () => {
|
||||
beforeAll(async () => {
|
||||
await ensureBuilt(pkgDir);
|
||||
}, 180_000);
|
||||
|
||||
test('shimsWorkerd calls preloadSchemas() at module scope (ESM and CJS)', () => {
|
||||
expect(dist('shimsWorkerd.mjs')).toMatch(MODULE_SCOPE_PRELOAD_CALL);
|
||||
expect(dist('shimsWorkerd.cjs')).toMatch(MODULE_SCOPE_PRELOAD_CALL);
|
||||
});
|
||||
|
||||
test('node and browser shims stay lazy — no preloadSchemas reference at all', () => {
|
||||
for (const shim of ['shimsNode.mjs', 'shimsNode.cjs', 'shimsBrowser.mjs', 'shimsBrowser.cjs']) {
|
||||
expect(dist(shim), `${shim} must not re-eagerize schema construction`).not.toMatch(/\bpreloadSchemas\b/);
|
||||
}
|
||||
});
|
||||
|
||||
test('the root entry exports preloadSchemas but never calls it at module scope', () => {
|
||||
const index = dist('index.mjs');
|
||||
expect(index).toMatch(/\bpreloadSchemas\b/);
|
||||
expect(index).not.toMatch(MODULE_SCOPE_PRELOAD_CALL);
|
||||
});
|
||||
|
||||
test('the workerd shim warms the same chunk the root entry uses (no duplicated schema graph)', () => {
|
||||
const entries = ['index.mjs', 'shimsWorkerd.mjs', 'shimsNode.mjs', 'shimsBrowser.mjs', 'stdio.mjs'];
|
||||
for (const entry of entries) {
|
||||
expect(dist(entry), `${entry} must not carry its own preloadSchemas definition`).not.toMatch(/function preloadSchemas\b/);
|
||||
}
|
||||
|
||||
const shimSource = dist('shimsWorkerd.mjs').match(PRELOAD_IMPORT)?.[1];
|
||||
const indexSource = dist('index.mjs').match(PRELOAD_IMPORT)?.[1];
|
||||
expect(shimSource).toBeDefined();
|
||||
expect(indexSource).toBeDefined();
|
||||
expect(shimSource).toBe(indexSource);
|
||||
expect(dist(shimSource!.replace('./', ''))).toMatch(/function preloadSchemas\b/);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ export default defineConfig({
|
|||
'src/stdio.ts',
|
||||
'src/shimsNode.ts',
|
||||
'src/shimsWorkerd.ts',
|
||||
'src/shimsBrowser.ts',
|
||||
'src/validators/ajv.ts',
|
||||
'src/validators/cfWorker.ts'
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue