diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index cb8070ae63..c163d61b9d 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -95,6 +95,12 @@ declare global { MCP_REQUEST_STATE_KEY?: string; /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ MCP_2026_07_28_ENABLED?: string; + // Kill switch for the Start server-graph warmup (server.ts). Set to + // "false" to disable without a deploy if it ever costs memory again. + START_GRAPH_WARM?: string; + // How many fetches an isolate must serve before it is considered + // persistent enough to warm. Defaults to 2. + START_GRAPH_WARM_AFTER?: string; NODE_ENV?: string; // Shared with frontend diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 0304ab0246..52da6cd201 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -178,8 +178,68 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ traceRequest: traceCloudMcpRequest, }); +// --------------------------------------------------------------------------- +// Start server-graph warmup +// --------------------------------------------------------------------------- +// +// TanStack Start loads the router + start instance behind a dynamic import on +// the first Start-handled request per isolate (`start-server-core`'s +// `loadEntries`). Measured in production by timing that import apart from the +// request's own work: the import is **p50 3.1s**, the work is **p50 33ms**, +// and a warm isolate answers in **9-104ms**. The same bundle in local workerd +// serves the same path in ~3ms, so this is a cold-isolate cost on production +// metal, not slow code. +// +// Nearly every page request pays it. `/mcp` is dispatched above, before +// `fetchHandler`, so MCP traffic — the large majority — creates and occupies +// isolates without ever loading the graph. Page requests then land on those: +// `worker.dispatch` ran 1,666 requests across 1,608 isolates (1.04 each). +// +// #1628 warmed on every isolate's FIRST fetch and was reverted: it pulled the +// SSR graph into every isolate including MCP-only ones, and memory-limit kills +// went from ~500-900 to 3.7k-9.4k per 5min. The fix for that is to stop +// warming isolates that will not live long enough to use it. An isolate that +// has already served several requests is one that persists — exactly the kind +// a later page request can land on — so warming waits for that evidence. +// Single-shot isolates, which is what the memory blowup was made of, are never +// warmed. +// +// Deliberately NOT at module scope: a full warmup there trips workerd's +// global-scope I/O restriction, and DO-only isolates should not carry the SSR +// graph. `START_GRAPH_WARM=false` disables it without a deploy. +// --------------------------------------------------------------------------- + +const DEFAULT_WARM_AFTER_FETCHES = 2; + +let isolateFetchCount = 0; +let startGraphWarmupStarted = false; + +const maybeWarmStartGraph = (env: Env): void => { + if (startGraphWarmupStarted) return; + if (env.START_GRAPH_WARM === "false") return; + + isolateFetchCount += 1; + const parsed = Number.parseInt(env.START_GRAPH_WARM_AFTER ?? "", 10); + const warmAfter = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_WARM_AFTER_FETCHES; + if (isolateFetchCount < warmAfter) return; + + startGraphWarmupStarted = true; + // oxlint-disable-next-line executor/no-promise-catch -- adapter boundary; fire-and-forget warmup outside any Effect runtime + void Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]).catch( + () => { + // Advisory only — the request path still loads the graph lazily. + startGraphWarmupStarted = false; + }, + ); +}; + const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + // Every request counts toward this isolate's persistence, including /mcp + // (which returns below without ever loading the graph) — MCP traffic is + // precisely what keeps these isolates alive for a later page request. + maybeWarmStartGraph(env); + // Public pages must not enter TanStack Start: its first-request dynamic // import loads the entire React + Effect server graph and can take seconds // on a cold isolate. Classify and service-bind marketing at the Worker diff --git a/apps/cloud/src/start-virtual-entries.d.ts b/apps/cloud/src/start-virtual-entries.d.ts new file mode 100644 index 0000000000..9fb43d8206 --- /dev/null +++ b/apps/cloud/src/start-virtual-entries.d.ts @@ -0,0 +1,8 @@ +// TanStack Start's internal virtual server-entry modules (registered by the +// Start vite plugin; the same ids `start-server-core`'s `loadEntries` +// imports). server.ts imports them for the isolate warmup — only the +// module-evaluation side effect matters there, so the value shape is left +// untyped. Kept in a standalone declaration file: shorthand ambient modules +// only register from a non-module file (env-augment.d.ts is a module). +declare module "#tanstack-router-entry"; +declare module "#tanstack-start-entry";