Skip to content

Instantly share code, notes, and snippets.

@hi-ogawa
Created June 23, 2026 08:19
Show Gist options
  • Select an option

  • Save hi-ogawa/752eee33ffb4cdb62893541b24714592 to your computer and use it in GitHub Desktop.

Select an option

Save hi-ogawa/752eee33ffb4cdb62893541b24714592 to your computer and use it in GitHub Desktop.
Cloudflare @cloudflare/vitest-pool-workers integration review (vitest-dev/vitest#10635)

Cloudflare @cloudflare/vitest-pool-workers — integration overview (#10635)

  • 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, peerDeps vitest@^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.

TL;DR

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.

Architecture (two halves + a socket)

            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)

Node side (the "pool")

packages/vitest-pool-workers/src/pool/

  • plugin.tscloudflareTest(options) returns a Vite plugin. This is the user-facing entry (plugins: [...] in vitest config). Key hooks:
    • configureVitest(ctx): sets ctx.project.config.poolRunner = cloudflarePool(options), ctx.project.config.pool = "cloudflare-pool", and snapshotEnvironment = "cloudflare:snapshot".
    • config(): rewrites resolve conditions (workerd/worker/module/browser, drops node), mainFields, forces ssr.target = "webworker", test.server.deps.inline = true, and hard-errors on v8 coverage (workerd's node:inspector is a non-functional stub → must use istanbul).
    • resolveId/load: virtualizes cloudflare:test, and injects a side-effect import of the user's main Worker so Vitest re-runs tests on Worker edits.
    • Exposes api.setMain() so the pool worker can hand back the resolved main path.
  • pool.tscloudflarePool() returns the PoolRunnerInitializer: { name: "cloudflare-pool", createPoolWorker: (opts) => new CloudflarePoolWorker(...) }.
  • cloudflare-pool-worker.tsCloudflarePoolWorker implements PoolWorker. This is the heart of the integration. See "PoolWorker contract" below.
  • index.ts — builds Miniflare WorkerOptions (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.

workerd side (the "worker")

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's init({...}) wiring the WS as the transport:
    • postpoolSocket.send(devalue.stringify(response))
    • onpoolSocket.onmessage → callback(devalue.parse(...))
    • runTests/collectTestsrunBaseTests("run"|"collect", state, traces)
    • setupsetupEnvironment
    • onModuleRunner → patches module runner transport to run inside the DO's I/O context (workaround for #12924).

How it maps onto Vitest's pool runner API

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 by Pool (pool.ts). PoolRunner owns the birpc instance and the WorkerRequest/WorkerResponse lifecycle; the custom PoolWorker only has to be a message transport + process lifecycle abstraction.

Lifecycle as exercised by Cloudflare:

  1. Pool.getPoolRunner matches task.worker === "cloudflare-pool" against project.config.poolRunner.name, then createPoolWorker(options).
  2. PoolRunner.start()worker.start() (boot Miniflare + connect WS), then attaches on('error'|'exit'|'message'), then posts { type: 'start' }.
  3. PoolRunner.request('run'|'collect', ctx)worker.send({ type: 'run' }).
  4. Worker streams back birpc RPC messages + a final { type: 'testfileFinished' }.
  5. PoolRunner.stop() posts { type: 'stop' }, waits for { type: 'stopped' }, then worker.stop() (dispose Miniflare).

CloudflarePoolWorker event mapping:

  • on('message') ↔ WS message, on('error') ↔ WS error, on('exit') ↔ WS close.
  • deserialize() is a no-op — it serializes/deserializes inside send/on instead, because Vitest has no matching serialize() hook (see gaps).

Notable Vitest-internal coupling / pain points

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).

  1. birpc message sniffing in on('message')cloudflare-pool-worker.ts inspects raw serialized birpc payloads (d.m === "fetch", d.a, d.i) to intercept module-load RPC calls and externalize cloudflare:/workerd:/ node builtins and module-rule matches (wasm etc). Comment literally points at vitest/src/types/rpc.ts#L8 and 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.

  2. No serialize() counterpart to deserialize() — they must serialize in send() and parse in on() manually with devalue. PoolWorker.deserialize exists but there is no serialize. (See deserialize() comment.)

  3. project.provide doesn't work for Vitest Projects — they bypass it and inject providedContext.cloudflarePoolOptions directly into the run message context. Comment: "providing this using the Vitest project.provide API doesn't work in Vitest Projects". → Worth confirming whether this is a real bug on Vitest side.

  4. SourceMap can't be serialized through devaluestructuredSerializableStringify strips r.map from messages before sending; TODO to figure out how to serialize SourceMap. → Vitest sends a sourcemap to its runner; devalue (structured-clone-ish) can't carry it.

  5. Inspector option interception — they force config.inspector.enabled = false in the start message so Vitest's in-worker code doesn't try to import('node:inspector').open() (no real inspector in workerd; they wire workerd's own inspector instead).

  6. setTimeout monkeypatch 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 the vi.js global-mock setup NOOP timer. Extremely brittle against Vitest internal file layout.

  7. Version assertion against internal-API driftassertCompatibleVitestVersion hard-errors on vitest v3 and warns outside the peer range, explicitly stating "@cloudflare/vitest-pool-workers currently depends on internal Vitest APIs that are not protected by semantic-versioning guarantees." Also has special handling for @voidzero-dev/vite-plus-test bundling vitest (Vite+).

  8. 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.

  9. Cross-DO I/O context workarounds (#12924) — onModuleRunner transport patch + runInRunnerObject resend logic in post. Dynamic import() and console.log() from inside user fetch/DO handlers run in a different IO context than the runner DO. They lean on the new onModuleRunner hook (good — that hook exists in init), but the surrounding gymnastics are heavy.

v5 forward-compat evaluation (#10635 checkbox)

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.isAgentconfig.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 update assertCompatibleVitestVersion (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 to 3.x only). 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.

Known issues — by layer (is it the integration boundary or not?)

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.

A. Integration boundary (Vitest ↔ pool) — relevant to #10635

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.

B. workerd / CF runtime layer — not Vitest's concern

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.

C. Vite layer (adjacent — not Vitest core)

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.

Things that work cleanly (Vitest API is sufficient)

  • The poolRunner / PoolWorker transport 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.
  • onModuleRunner hook in init is used as intended.
  • snapshotEnvironment extension point ("cloudflare:snapshot") is used as a public hook.
  • Vite plugin configureVitest is the documented way to set pool config.

Open questions / next steps

  • Confirm whether project.provide for Vitest Projects is genuinely broken (pain point #3) — reproduce on current main.
  • 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 to PoolWorker.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.

Key files

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/worker init
  • packages/vitest/src/public/worker.ts — init/runBaseTests/setupEnvironment exports

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment