- Issue: vitest-dev/vitest#10635 (Research and feedback for downstream integrations)
- Vitest repo: git@github.com:hi-ogawa/vitest.git
- Vitest commit: 76139c5a001b9d3dd880e79c2bd83861d1c98bda (branch
main) - Vitest worktree: /Users/hogawa/code/others/vitest
- workers-sdk repo: https://github.com/cloudflare/workers-sdk
- workers-sdk commit: 2091f804396f28a700fb90420e6ecf8c46336702
- Pool package:
@cloudflare/vitest-pool-workers@0.16.18, peerDepsvitest@^4.1.0 - Pool source:
~/code/others/workers-sdk/packages/vitest-pool-workers
Status: rough first pass. Goal is a high-level picture of how the pool embeds Vitest and which Vitest APIs (public + internal) it leans on, so we can spot API gaps / docs follow-ups on the Vitest side.
The pool runs each Vitest test file inside the workerd runtime (via
Miniflare), not in a Node worker/fork. It does this by implementing Vitest's
custom pool runner API (PoolRunnerInitializer / PoolWorker) on the Node
side, and by re-using Vitest's vitest/worker runtime entrypoint (init,
runBaseTests, setupEnvironment) on the workerd side. Communication between
the two halves is a WebSocket to a singleton Durable Object, carrying the exact
same WorkerRequest/WorkerResponse + birpc protocol that Vitest's built-in
forks/threads pools use over node:worker_threads / child-process IPC.
This is a v4-era integration: it targets the new poolRunner API (added in
v4), replacing the old pool + poolOptions custom-pool mechanism from v3.
Node.js (Vitest host process) workerd (Miniflare)
┌─────────────────────────────────────────┐ ┌──────────────────────────────────┐
│ Vitest core / Pool / PoolRunner │ │ Runner Durable Object (singleton)│
│ │ │ __VITEST_POOL_WORKERS_RUNNER_.. │
│ cloudflareTest() Vite plugin │ │ │
│ - configureVitest(): sets │ │ worker/index.ts: │
│ project.config.poolRunner = │ │ import { init, runBaseTests, │
│ cloudflarePool(opts) │ │ setupEnvironment } │
│ project.config.pool = "cloudflare.."│ │ from "vitest/worker" │
│ snapshotEnvironment = │ │ init({ post, on, runTests, │
│ "cloudflare:snapshot" │ │ collectTests, setup, │
│ │ │ onModuleRunner }) │
│ cloudflarePool() => PoolRunnerInitializer│ │ │
│ .createPoolWorker(opts) => │ │ │
│ CloudflarePoolWorker (PoolWorker) │ WS │ │
│ start(): boot Miniflare, │◄───►│ poolSocket (WebSocketPair) │
│ connect WS to Runner DO │ │ │
│ send()/on()/off(): bridge │ │ │
│ WorkerRequest/Response over WS │ │ module fallback service ───────► │
└─────────────────────────────────────────┘ └──────────────────────────────────┘
▲ module fallback / loopback HTTP back into Node (Vite dev server)
packages/vitest-pool-workers/src/pool/
plugin.ts—cloudflareTest(options)returns a Vite plugin. This is the user-facing entry (plugins: [...]in vitest config). Key hooks:configureVitest(ctx): setsctx.project.config.poolRunner = cloudflarePool(options),ctx.project.config.pool = "cloudflare-pool", andsnapshotEnvironment = "cloudflare:snapshot".config(): rewrites resolveconditions(workerd/worker/module/browser, dropsnode),mainFields, forcesssr.target = "webworker",test.server.deps.inline = true, and hard-errors on v8 coverage (workerd'snode:inspectoris a non-functional stub → must use istanbul).resolveId/load: virtualizescloudflare:test, and injects a side-effect import of the user'smainWorker so Vitest re-runs tests on Worker edits.- Exposes
api.setMain()so the pool worker can hand back the resolved main path.
pool.ts—cloudflarePool()returns thePoolRunnerInitializer:{ name: "cloudflare-pool", createPoolWorker: (opts) => new CloudflarePoolWorker(...) }.cloudflare-pool-worker.ts—CloudflarePoolWorker implements PoolWorker. This is the heart of the integration. See "PoolWorker contract" below.index.ts— builds MiniflareWorkerOptions(compat flags, DO wrappers, module injection, defines), boots Miniflare, opens the WS to the Runner DO.module-fallback.ts/loopback.ts— HTTP services workerd calls back into Node for module resolution (delegates to Vite) and helper RPC.
packages/vitest-pool-workers/src/worker/index.ts
- A singleton Durable Object
__VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__hosts the Vitest runtime. All test files for a project run inside this one DO. - On WS upgrade it does
await import("vitest/worker")and calls Vitest'sinit({...})wiring the WS as the transport:post→poolSocket.send(devalue.stringify(response))on→poolSocket.onmessage → callback(devalue.parse(...))runTests/collectTests→runBaseTests("run"|"collect", state, traces)setup→setupEnvironmentonModuleRunner→ patches module runner transport to run inside the DO's I/O context (workaround for #12924).
Vitest v4 custom pool surface (in packages/vitest/src/node/pools/types.ts,
re-exported from vitest/node):
PoolRunnerInitializer { name, createPoolWorker(opts): PoolWorker }PoolWorker { name, reportMemory?, cacheFs?, on, off, send, deserialize, start, stop, canReuse? }- Driven by the internal
PoolRunner(poolRunner.ts) which itself is driven byPool(pool.ts).PoolRunnerowns the birpc instance and theWorkerRequest/WorkerResponselifecycle; the customPoolWorkeronly has to be a message transport + process lifecycle abstraction.
Lifecycle as exercised by Cloudflare:
Pool.getPoolRunnermatchestask.worker === "cloudflare-pool"againstproject.config.poolRunner.name, thencreatePoolWorker(options).PoolRunner.start()→worker.start()(boot Miniflare + connect WS), then attacheson('error'|'exit'|'message'), then posts{ type: 'start' }.PoolRunner.request('run'|'collect', ctx)→worker.send({ type: 'run' }).- Worker streams back birpc RPC messages + a final
{ type: 'testfileFinished' }. PoolRunner.stop()posts{ type: 'stop' }, waits for{ type: 'stopped' }, thenworker.stop()(dispose Miniflare).
CloudflarePoolWorker event mapping:
on('message')↔ WSmessage,on('error')↔ WSerror,on('exit')↔ WSclose.deserialize()is a no-op — it serializes/deserializes insidesend/oninstead, because Vitest has no matchingserialize()hook (see gaps).
These are the spots where the integration reaches past Vitest's stable public API. Each is a candidate for a Vitest-side follow-up (API surface or docs).
-
birpc message sniffing in
on('message')—cloudflare-pool-worker.tsinspects raw serialized birpc payloads (d.m === "fetch",d.a,d.i) to intercept module-load RPC calls and externalizecloudflare:/workerd:/ node builtins and module-rule matches (wasm etc). Comment literally points atvitest/src/types/rpc.ts#L8and warns the shape is unintelligible/fragile. → Possible gap: no public hook for "intercept/resolve a module fetch from the runtime". They reverse-engineer the RPC wire format. -
No
serialize()counterpart todeserialize()— they must serialize insend()and parse inon()manually with devalue.PoolWorker.deserializeexists but there is noserialize. (Seedeserialize()comment.) -
project.providedoesn't work for Vitest Projects — they bypass it and injectprovidedContext.cloudflarePoolOptionsdirectly into therunmessage context. Comment: "providing this using the Vitestproject.provideAPI doesn't work in Vitest Projects". → Worth confirming whether this is a real bug on Vitest side. -
SourceMap can't be serialized through devalue —
structuredSerializableStringifystripsr.mapfrom messages before sending; TODO to figure out how to serializeSourceMap. → Vitest sends a sourcemap to its runner; devalue (structured-clone-ish) can't carry it. -
Inspector option interception — they force
config.inspector.enabled = falsein thestartmessage so Vitest's in-worker code doesn't try toimport('node:inspector').open()(no real inspector in workerd; they wire workerd's own inspector instead). -
setTimeoutmonkeypatch keyed on Vitest internals — worker/index.ts detects "is this setTimeout call coming from Vitest?" by matching caller file path regexes (/node_modules/.../vitest,/packages/vitest/dist, and even@voidzero-dev/vite-plus-test). Also special-cases thevi.jsglobal-mock setupNOOPtimer. Extremely brittle against Vitest internal file layout. -
Version assertion against internal-API drift —
assertCompatibleVitestVersionhard-errors on vitest v3 and warns outside the peer range, explicitly stating "@cloudflare/vitest-pool-workerscurrently depends on internal Vitest APIs that are not protected by semantic-versioning guarantees." Also has special handling for@voidzero-dev/vite-plus-testbundling vitest (Vite+). -
Imports Miniflare internals by relative path in the worker bundle (
../../../miniflare/src/workers/core/devalue) to share the exact reducers/revivers — necessary because the same devalue config must be used on both ends of the socket. Not a Vitest issue but shows the serialization contract is private. -
Cross-DO I/O context workarounds (#12924) —
onModuleRunnertransport patch +runInRunnerObjectresend logic inpost. Dynamicimport()andconsole.log()from inside user fetch/DO handlers run in a different IO context than the runner DO. They lean on the newonModuleRunnerhook (good — that hook exists ininit), but the surrounding gymnastics are heavy.
Checked @cloudflare/vitest-pool-workers@0.16.18 (peer vitest@^4.1.0) against
this checkout, which is vitest@5.0.0-beta.5. Method: read the v5 migration
guide (docs/guide/migration.md → "Migrating to Vitest 5.0"), diff the
pool-facing surfaces between v4.1.9..HEAD, and grep the CF source for each
documented breaking change.
Verdict: no concrete blocker found. v4→v5 is much lighter than v3→v4 for this pool. The only required downstream change is a version-range bump.
Evidence per v5 breaking change:
| v5 breaking change | Touches CF pool? | Notes |
|---|---|---|
Removed deprecated entrypoints (vitest/snapshot, vitest/mocker, vitest/coverage, vitest/reporters, vitest/environments, vitest/runners, vitest/suite, vitest/internal/module-runner) |
No | CF already imports the v5-safe vitest/runtime for VitestSnapshotEnvironment (snapshot.ts:4). All other CF imports use surviving entries: vitest, vitest/node, vitest/worker, vitest/config. Verified all still exported in v5. |
Benchmarking API rewrite (bench is now a test-context fixture; new RPC methods onTestBenchmark/`read |
writeBenchmarkResult`) | No |
Serialized config rename config.isAgent → config.disableColors (init.ts) |
No | CF spreads message.context.config untouched except inspector; no isAgent/disableColors refs. init.ts is bundled with vitest, so it reads the right field. |
PoolWorker.on/off signature widened (arg:any) → (...args:any[]) |
Compatible | CF's impl accepts the callbacks fine; type-only widening. |
init.ts adds concurrencyId: poolId to run/collect |
No | Internal, bundled with vitest/worker. |
Removed test.sequential / sequential; v8 coverage; UI auth; locators; .vitest dir; config parent-dir lookup; DOM globals |
No | None are pool-runner concerns (user-test / browser / CLI / coverage surface). |
Required downstream change (CF side, one-liner, not a Vitest gap):
- Bump
peerDependencies.vitest(and@vitest/runner/@vitest/snapshot) to include^5.0.0, and updateassertCompatibleVitestVersion(pool/index.ts:782). Today on v5 it does not error — it only emits the "officially supports vitest 4.1" warning (hard error is gated to3.xonly). So tests likely run on v5 already, just noisily.
Forward-compat risk (carries past v5, ties to pain points above): the real
exposure is the unprotected internal couplings — birpc fetch wire format
(#1), the serialized-config shape CF mutates for inspector (#5), and the
Vitest-internal file-path regexes in the setTimeout patch (#6). None broke in
this v5 beta, but they are exactly what a future major could shift silently.
This is the substance behind CF's own "depends on internal Vitest APIs not
protected by semver" warning (#7).
→ Net for #10635: the v5 checkbox for Cloudflare is green (no Vitest-side action required for v5). The follow-up value is in hardening the internal surfaces (#1/#5/#6) so future majors stay green too.
Triage of known/open issues, classified by where the root cause lives. Only the boundary layer (Vitest ↔ pool/runner API, module-runner/Vite resolution bridge, serialization, mocker, snapshot, coverage abstraction) is actionable feedback for #10635. The workerd/CF runtime and Vite version layers are listed for completeness but are not Vitest-side concerns.
| Issue | What | Boundary it sits on |
|---|---|---|
| workers-sdk#14283 | Module fallback service does one sequential HTTP round-trip per node_modules import — O(modules) before tests run. Asks for pre-injecting resolved modules. (unfinished half of #5395; workerd#6115 did connection reuse) |
Vitest/Vite module-runner ↔ workerd resolution. Perf. This is the #1 perf lever and the strongest #10635 candidate. |
| workers-sdk#12924 | Dynamic import() inside entrypoint/DO handlers fails with cross-DO I/O error; worked around by patching the module-runner transport via onModuleRunner. |
Vitest module-runner transport ↔ workerd I/O context. The hook exists; the need for it is the signal. |
| workers-sdk#10201 | vi.mock() not working with cloudflare:test setup-file imports. |
Vitest mocker ↔ pool. |
| workers-sdk#7679 | __mocks__ auto-mocks not working under the pool. |
Vitest mocker ↔ pool. |
| workers-sdk#5266 | V8 coverage unusable (workerd's node:inspector is a stub) → plugin hard-errors and forces istanbul. |
Vitest coverage provider assumes a working node:inspector. Portability gap. |
| workers-sdk#13037 | require() resolves ESM instead of CJS for transitive deps (mimetext, mime-types). |
Vite/SSR resolve conditions the plugin sets (workerd/worker/module, minus import). Boundary-adjacent. |
| Issue | What |
|---|---|
| workers-sdk#14392 | reset helper doesn't affect ratelimit bindings (CF helper). |
| workers-sdk#14180 | Teardown hang when a DO blockConcurrencyWhile IIFE both console.*s and throws (workerd DO semantics; teardown-lifecycle flavor but rooted in workerd). |
| workers-sdk#10408 | Cloudflare Containers don't work with the pool (CF runtime feature). |
| workers-sdk#13306 | Network connection lost on multiple remote AI requests (CF runtime). |
| workers-sdk#11121 | D1 not available when running worker as a Service binding locally (Miniflare bindings). |
| workers-sdk#13639 | Vitest + DOs + WebSockets (CF runtime). |
| workers-sdk#14214 | No such module "cloudflare:test-internal" on Windows (CF packaging/path). |
| workerd#6110 (in code) | DOs on disk hit a workerd SQLite Windows path bug → runner DO forced ephemeral. |
| Issue | What |
|---|---|
| workers-sdk#12984 | Vite 8 breaks importing pg (Vite version compat). |
| workers-sdk#7157 | Yarn PnP compatibility error (packaging/resolution). |
| mswjs/msw#2637 | MSW msw/node doesn't work in Workers → plugin rewrites it to msw/native (third-party). |
Note: the GitHub search also surfaced pure wrangler / Workers-platform issues (#13747, #11270, #11629, #13642, #14056, #10371, #10286) with no relation to the pool — excluded.
Takeaway: the boundary-layer cluster is small and coherent — module resolution/perf (#14283, #13037), module-runner I/O context (#12924), mocker (#10201, #7679), and coverage portability (#5266). Everything else is workerd/CF runtime or Vite-version churn. For #10635, #14283 (perf) and the mocker pair are the most concrete Vitest-side conversations.
- The
poolRunner/PoolWorkertransport abstraction itself fits well: the whole "run Vitest in a totally non-Node runtime" is expressed without forking Vitest. This is the v4 API doing its job. vitest/worker(init,runBaseTests,setupEnvironment) is a clean reuse of the runtime entrypoint; no patching of the runner needed.onModuleRunnerhook ininitis used as intended.snapshotEnvironmentextension point ("cloudflare:snapshot") is used as a public hook.- Vite plugin
configureVitestis the documented way to set pool config.
- Confirm whether
project.providefor Vitest Projects is genuinely broken (pain point #3) — reproduce on currentmain. - Decide if Vitest should expose a public "module fetch interception" hook so the pool doesn't sniff birpc wire format (#1).
- Consider a
serialize()counterpart toPoolWorker.deserialize()(#2). - SourceMap serialization story for runner messages (#4).
- Stabilize whatever internal surface the version warning (#7) refers to, or document the supported subset for downstream pools.
- (Later, separate from Cloudflare) Astro + Vite+ integrations per #10635.
Vitest side:
- packages/vitest/src/node/pools/types.ts —
PoolRunnerInitializer/PoolWorker/WorkerRequest/WorkerResponse - packages/vitest/src/node/pools/pool.ts:259 — custom pool dispatch
- packages/vitest/src/node/pools/poolRunner.ts — lifecycle + birpc owner
- packages/vitest/src/runtime/workers/init.ts —
vitest/workerinit - packages/vitest/src/public/worker.ts —
init/runBaseTests/setupEnvironmentexports
Cloudflare side:
- packages/vitest-pool-workers/src/pool/plugin.ts
- packages/vitest-pool-workers/src/pool/pool.ts
- packages/vitest-pool-workers/src/pool/cloudflare-pool-worker.ts
- packages/vitest-pool-workers/src/pool/index.ts
- packages/vitest-pool-workers/src/worker/index.ts