/** * @fileoverview SSR module-runner resilience — a transient failure must not * outlive its cause. * * ## The measured defect (builder cycle 2026-07-29, 4 of 9 pods) * * On a cold pod the FIRST `GET /` races the SSR environment's dep optimizer. * The optimizer holds `deps_ssr` bundling until crawl end by default * (`optimizeDeps.holdUntilCrawlEnd: true`), so under load the module runner's * `fetchModule("/node_modules/.vite/deps_ssr/react.js")` exceeds the * transport's 60s RPC deadline and rejects with * `transport invoke timed out after 60000ms (…)`. * * That rejection would be survivable — the optimizer finishes seconds later and * the file exists on disk — except vite memoizes it: `ModuleRunner.cachedRequest` * stores the in-flight promise on the module node and, in a `finally`, marks the * node `evaluated = true` WITHOUT clearing the promise on rejection * (`vite/dist/node/module-runner.js`, `cachedRequest`). Every later request * short-circuits into `await ` — which is why a poisoned * pod serves the identical 500 in 0.03s forever while the dependency it * "cannot fetch" sits fully built on disk. * * TanStack Start's dev middleware is terminal and swallows the error into a 500 * with no `next()`, so no connect error-middleware can see it. The ONLY seam * that can heal it is the runner itself — and the recovery incantation is the * one Start already uses behind `experimental.bundledDev` * (`start-plugin-core` dev-server-plugin): `moduleGraph.invalidateAll()` + * `runner.clearCache()`, which drops the evaluated-module maps so the next * request mints fresh nodes and re-reads the (now present) optimized deps. * * ## What this module does * * 1. {@link ssrRunnerResiliencePlugin} — a `configEnvironment` hook for the * `ssr` environment ONLY: * - sets `optimizeDeps.holdUntilCrawlEnd: false`, so the optimizer commits * as soon as the scan lands instead of waiting for crawl end — attacking * the measured >60s hold directly (vite re-runs the optimizer if the * crawl later finds deps the scan missed; a re-bundle plus a benign * `ERR_OUTDATED_OPTIMIZED_DEP` retry is strictly better than permanent); * - installs a `dev.createEnvironment` that is exactly vite's own default * (`createRunnableDevEnvironment`) plus a runner factory wrapping * `createServerModuleRunner` with the recovery below. The seam is * unclaimed today (neither the template nor TanStack Start sets one) and * an incoming `dev.createEnvironment` is NEVER overwritten. * * 2. {@link wrapSsrRunnerWithRecovery} — wraps `runner.import` so a * transport-timeout rejection triggers ONE single-flight heal * (`invalidateAll` + `clearCache`) and ONE cold retry: * - single-flight: concurrent poisoned imports share one heal — a request * storm must not become a clear-loop, each clear re-poisoning its * neighbours' in-flight promises; * - retry exactly once: a loop would convert a hard failure into a hang, * which is worse than the 500 it replaces; * - any OTHER error class propagates untouched with ZERO heals, so a real * app-code failure is never masked (and never pays a graph rebuild). * * ## Known fragility * * The detector matches vite's error MESSAGE — the timeout rejection carries no * error code (`module-runner.js`, `transport invoke timed out after …`). A vite * reword silently reverts the recovery; the spec pins the string against the * installed vite dist so a bump that moves it fails the suite instead of the * fleet. */ import type { EnvironmentOptions, Plugin } from 'vite'; /** The one environment this plugin touches — TanStack Start's server env. */ export declare const SSR_ENVIRONMENT_NAME = "ssr"; /** * The load-bearing substring of vite's module-runner transport-timeout * rejection (`transport invoke timed out after ${timeout}ms (…)`). There is no * error code on this rejection; the message is the only classifier available. */ export declare const SSR_TRANSPORT_TIMEOUT_ERROR_SNIPPET = "transport invoke timed out after"; /** * Is this the memoizable transport-timeout rejection (or its cached replay)? * The replay IS the original Error object — vite re-awaits the same rejected * promise — so first failure and every subsequent one match identically. */ export declare function isSsrTransportTimeoutError(err: unknown): boolean; /** * The two runner operations recovery needs — structural, so the wrapper is * testable without constructing a real `ModuleRunner` (whose constructor wants * a live transport). `import` is deliberately non-generic here: the wrapper * neither knows nor cares what the module exports, and `Promise` is * what lets both the real runner and a spec double satisfy this without a * single cast. */ export interface SsrRunnerLike { import(url: string): Promise; clearCache(): void; } /** The one environment operation recovery needs. */ export interface SsrEnvironmentGraphLike { moduleGraph: { invalidateAll(): void; }; } /** * Wrap a runner's `import` with the timeout-poisoning recovery. Mutates and * returns the SAME runner instance so every holder of the reference (vite keeps * it on the environment) sees the wrapped behaviour. */ export declare function wrapSsrRunnerWithRecovery(runner: R, environment: SsrEnvironmentGraphLike): R; /** * The `configEnvironment` body, exported so the spec exercises the real * decision table without mocking a vite plugin context. * * Returns the environment-options PATCH for `name`, or `null` when this * environment is not ours to touch. Vite merges the returned patch into * `environments[name]` AFTER the user config, so `holdUntilCrawlEnd: false` * wins, while `dev.createEnvironment` is only ever contributed when the seam * is unclaimed. */ export declare function resolveSsrEnvironmentPatch(name: string, config: EnvironmentOptions): Promise; /** * The plugin. `configEnvironment` rather than `config` so the patch lands on * the `ssr` environment by NAME and merges after the user config (vite merges * each hook's return into `environments[name]`). */ export declare function ssrRunnerResiliencePlugin(): Plugin; //# sourceMappingURL=ssr-runner-resilience.d.ts.map