diff --git a/.changeset/d1-binding-config-validation.md b/.changeset/d1-binding-config-validation.md new file mode 100644 index 0000000000..d78efcfed4 --- /dev/null +++ b/.changeset/d1-binding-config-validation.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/workers-utils": patch +--- + +Validate optional configuration fields for D1 database bindings + +Enforce type checks for the optional D1 database properties `database_name`, `migrations_dir`, `migrations_table`, and `database_internal_env` to ensure consistency with other binding types. diff --git a/.changeset/signal-exit-esm.md b/.changeset/signal-exit-esm.md new file mode 100644 index 0000000000..dbd155d9cc --- /dev/null +++ b/.changeset/signal-exit-esm.md @@ -0,0 +1,8 @@ +--- +"wrangler": patch +"@cloudflare/workers-utils": patch +--- + +Upgrade `signal-exit` from v3 to v4 + +The bundled `signal-exit` dependency was CJS-only. Upgrading to v4 (which ships a dual ESM/CJS build) unblocks ESM output. Exit-cleanup behaviour is unchanged, though v4 no longer registers handlers for a few signals that are no longer supported by the OS (`SIGUNUSED` on Linux; `SIGABRT`/`SIGALRM` on Windows). diff --git a/.changeset/vitest-pool-workers-reset-ratelimit.md b/.changeset/vitest-pool-workers-reset-ratelimit.md new file mode 100644 index 0000000000..5101d6b8dc --- /dev/null +++ b/.changeset/vitest-pool-workers-reset-ratelimit.md @@ -0,0 +1,6 @@ +--- +"miniflare": patch +"@cloudflare/vitest-pool-workers": patch +--- + +`reset()` from `cloudflare:test` now resets ratelimit binding state between tests. Previously, `RATE_LIMITERS` bindings retained their in-memory bucket counts across test boundaries, causing later tests in the same file to see stale rate-limit exhaustion state. diff --git a/fixtures/vitest-pool-workers-examples/reset/src/env.d.ts b/fixtures/vitest-pool-workers-examples/reset/src/env.d.ts index a6b7969c20..88dc8c77b0 100644 --- a/fixtures/vitest-pool-workers-examples/reset/src/env.d.ts +++ b/fixtures/vitest-pool-workers-examples/reset/src/env.d.ts @@ -1,16 +1,16 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./reset/src/env.d.ts -c ./reset/wrangler.jsonc --no-include-runtime` (hash: 524c60174bc1732153e84c1b8699023e) -interface __BaseEnv_Env { - KV_NAMESPACE: KVNamespace; - R2_BUCKET: R2Bucket; - DATABASE: D1Database; - COUNTER: DurableObjectNamespace; -} +// Generated by Wrangler by running `wrangler types src/env.d.ts -c wrangler.jsonc --no-include-runtime` (hash: 6992c0652c326145f228d37275b18131) declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./index"); durableNamespaces: "Counter"; } - interface Env extends __BaseEnv_Env {} + interface Env { + KV_NAMESPACE: KVNamespace; + R2_BUCKET: R2Bucket; + DATABASE: D1Database; + RATE_LIMITER: RateLimit; + COUNTER: DurableObjectNamespace; + } } -interface Env extends __BaseEnv_Env {} +interface Env extends Cloudflare.Env {} diff --git a/fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts b/fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts index a59ef3f294..04382ac223 100644 --- a/fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts +++ b/fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts @@ -62,3 +62,24 @@ it("sees reset Durable Object storage after reset", async ({ expect }) => { const response = await stub.fetch("https://example.com"); expect(await response.text()).toBe("1"); }); + +it("exhausts ratelimit then resets between tests", async ({ expect }) => { + // First call succeeds + const first = await env.RATE_LIMITER.limit({ key: "test-key" }); + expect(first.success).toBe(true); + + // Exhaust the full limit of 100 + for (let i = 1; i < 100; i++) { + await env.RATE_LIMITER.limit({ key: "test-key" }); + } + + // 101st call should fail + const over = await env.RATE_LIMITER.limit({ key: "test-key" }); + expect(over.success).toBe(false); +}); + +it("sees reset ratelimit state after reset", async ({ expect }) => { + // After reset(), buckets should be cleared — first call succeeds again + const result = await env.RATE_LIMITER.limit({ key: "test-key" }); + expect(result.success).toBe(true); +}); diff --git a/fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc index b7ef94bb04..35a3cc4975 100644 --- a/fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc +++ b/fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc @@ -34,4 +34,11 @@ "database_id": "00000000-0000-0000-0000-000000000000", }, ], + "ratelimits": [ + { + "name": "RATE_LIMITER", + "namespace_id": "1", + "simple": { "limit": 100, "period": 60 }, + }, + ], } diff --git a/packages/miniflare/src/plugins/ratelimit/index.ts b/packages/miniflare/src/plugins/ratelimit/index.ts index 2d36f91d9e..a68e5aff49 100644 --- a/packages/miniflare/src/plugins/ratelimit/index.ts +++ b/packages/miniflare/src/plugins/ratelimit/index.ts @@ -1,7 +1,20 @@ -import SCRIPT_RATELIMIT_OBJECT from "worker:ratelimit/ratelimit"; +import SCRIPT_RATELIMIT_CLIENT from "worker:ratelimit/ratelimit"; +import SCRIPT_RATELIMIT_OBJECT from "worker:ratelimit/ratelimit-object"; import { z } from "zod"; -import { ProxyNodeBinding } from "../shared"; -import type { Worker_Binding } from "../../runtime"; +import { kVoid } from "../../runtime"; +import { SharedBindings } from "../../workers"; +import { + getMiniflareObjectBindings, + getUserBindingServiceName, + objectEntryWorker, + ProxyNodeBinding, + SERVICE_LOOPBACK, +} from "../shared"; +import type { + Service, + Worker_Binding, + Worker_Binding_DurableObjectNamespaceDesignator, +} from "../../runtime"; import type { Plugin } from "../shared"; export enum PeriodType { @@ -14,7 +27,7 @@ export const RatelimitConfigSchema = z.object({ limit: z.number().gt(0), // may relax this to be any number in the future - period: z.nativeEnum(PeriodType).optional(), + period: z.nativeEnum(PeriodType).optional().default(PeriodType.MINUTE), }), }); export const RatelimitOptionsSchema = z.object({ @@ -24,6 +37,12 @@ export const RatelimitOptionsSchema = z.object({ export const RATELIMIT_PLUGIN_NAME = "ratelimit"; const SERVICE_RATELIMIT_PREFIX = `${RATELIMIT_PLUGIN_NAME}`; const SERVICE_RATELIMIT_MODULE = `cloudflare-internal:${SERVICE_RATELIMIT_PREFIX}:module`; +const RATELIMIT_ENTRY_SERVICE_PREFIX = `${RATELIMIT_PLUGIN_NAME}:ns`; +const RATELIMIT_OBJECT_CLASS_NAME = "RateLimiterObject"; +const RATELIMIT_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { + serviceName: RATELIMIT_ENTRY_SERVICE_PREFIX, + className: RATELIMIT_OBJECT_CLASS_NAME, +}; function buildJsonBindings(bindings: Record): Worker_Binding[] { return Object.entries(bindings).map(([name, value]) => ({ @@ -44,11 +63,21 @@ export const RATELIMIT_PLUGIN: Plugin = { name, wrapped: { moduleName: SERVICE_RATELIMIT_MODULE, - innerBindings: buildJsonBindings({ - namespaceId: name, - limit: config.simple.limit, - period: config.simple.period, - }), + innerBindings: [ + { + name: "fetcher", + service: { + name: getUserBindingServiceName( + RATELIMIT_ENTRY_SERVICE_PREFIX, + name + ), + }, + }, + ...buildJsonBindings({ + limit: config.simple.limit, + period: config.simple.period, + }), + ], }, }) ); @@ -65,8 +94,46 @@ export const RATELIMIT_PLUGIN: Plugin = { ]) ); }, - async getServices() { - return []; + async getServices({ options, unsafeStickyBlobs }) { + if (!options.ratelimits) { + return []; + } + const names = Object.keys(options.ratelimits); + const services = names.map((name) => ({ + name: getUserBindingServiceName(RATELIMIT_ENTRY_SERVICE_PREFIX, name), + worker: objectEntryWorker(RATELIMIT_OBJECT, name), + })); + + const uniqueKey = `miniflare-${RATELIMIT_OBJECT_CLASS_NAME}`; + services.push({ + name: RATELIMIT_ENTRY_SERVICE_PREFIX, + worker: { + compatibilityDate: "2023-07-24", + compatibilityFlags: ["nodejs_compat", "experimental"], + modules: [ + { + name: "ratelimit-object.worker.js", + esModule: SCRIPT_RATELIMIT_OBJECT(), + }, + ], + durableObjectNamespaces: [ + { className: RATELIMIT_OBJECT_CLASS_NAME, uniqueKey }, + ], + // Counters are only ever needed for the lifetime of the Miniflare + // process, never persisted across restarts (matching the previous + // purely in-memory implementation). + durableObjectStorage: { inMemory: kVoid }, + bindings: [ + { + name: SharedBindings.MAYBE_SERVICE_LOOPBACK, + service: { name: SERVICE_LOOPBACK }, + }, + ...getMiniflareObjectBindings(unsafeStickyBlobs), + ], + }, + }); + + return services; }, getExtensions({ options }) { if (!options.some((o) => o.ratelimits)) { @@ -77,7 +144,7 @@ export const RATELIMIT_PLUGIN: Plugin = { modules: [ { name: SERVICE_RATELIMIT_MODULE, - esModule: SCRIPT_RATELIMIT_OBJECT(), + esModule: SCRIPT_RATELIMIT_CLIENT(), internal: true, }, ], diff --git a/packages/miniflare/src/workers/ratelimit/ratelimit-object.worker.ts b/packages/miniflare/src/workers/ratelimit/ratelimit-object.worker.ts new file mode 100644 index 0000000000..51d26b98e6 --- /dev/null +++ b/packages/miniflare/src/workers/ratelimit/ratelimit-object.worker.ts @@ -0,0 +1,39 @@ +// Durable Object backing the emulated Ratelimit binding. Moving bucket/epoch +// state here (instead of an in-memory object behind a `wrapped` binding) +// means it's automatically torn down by `deleteAllDurableObjects()`, so +// vitest-pool-workers' `reset()` clears it for free. +import { MiniflareDurableObject, POST } from "miniflare:shared"; +import type { RouteHandler } from "miniflare:shared"; + +interface LimitRequestBody { + key: string; + limit: number; + period: number; +} + +interface LimitResult { + success: boolean; +} + +export class RateLimiterObject extends MiniflareDurableObject { + #buckets = new Map(); + #epoch = 0; + + @POST("/limit") + limit: RouteHandler = async (req) => { + const { key, limit, period } = await req.json(); + + const epoch = Math.floor(Date.now() / (period * 1000)); + if (epoch !== this.#epoch) { + this.#epoch = epoch; + this.#buckets.clear(); + } + + const value = this.#buckets.get(key) ?? 0; + if (value >= limit) { + return Response.json({ success: false } satisfies LimitResult); + } + this.#buckets.set(key, value + 1); + return Response.json({ success: true } satisfies LimitResult); + }; +} diff --git a/packages/miniflare/src/workers/ratelimit/ratelimit.worker.ts b/packages/miniflare/src/workers/ratelimit/ratelimit.worker.ts index 30408aae21..6b4e1a5dc0 100644 --- a/packages/miniflare/src/workers/ratelimit/ratelimit.worker.ts +++ b/packages/miniflare/src/workers/ratelimit/ratelimit.worker.ts @@ -1,10 +1,14 @@ // Emulated Ratelimit Binding +// +// Thin client: keeps option validation local (so error messages surface +// synchronously in the caller's isolate), forwards each `.limit()` call to +// the `RateLimiterObject` Durable Object, which owns the bucket/epoch state. // ENV configuration interface RatelimitConfig { - namespaceId: number; limit: number; period: number; + fetcher: Fetcher; } // options for Ratelimit @@ -25,22 +29,18 @@ function validate(test: boolean, message: string): asserts test { } class Ratelimit { - namespaceId: number; + fetcher: Fetcher; limitVal: number; period: number; - buckets: Map; - epoch: number; constructor(config: RatelimitConfig) { - this.namespaceId = config.namespaceId; + this.fetcher = config.fetcher; this.limitVal = config.limit; this.period = config.period; - - this.buckets = new Map(); - this.epoch = 0; } - // method that counts and checks against the limit in in-memory buckets + // method that counts and checks against the limit, delegating the actual + // bucket/epoch bookkeeping to the `RateLimiterObject` Durable Object async limit(options: unknown): Promise { // validate options input validate( @@ -67,22 +67,11 @@ class Ratelimit { `unsupported period: ${period}` ); - const epoch = Math.floor(Date.now() / (period * 1000)); - if (epoch != this.epoch) { - // clear counters - this.epoch = epoch; - this.buckets.clear(); - } - const val = this.buckets.get(key) || 0; - if (val >= limit) { - return { - success: false, - }; - } - this.buckets.set(key, val + 1); - return { - success: true, - }; + const res = await this.fetcher.fetch("http://ratelimit/limit", { + method: "POST", + body: JSON.stringify({ key, limit, period }), + }); + return await res.json(); } } diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json index a8696ebf53..3b0db31323 100644 --- a/packages/workers-utils/package.json +++ b/packages/workers-utils/package.json @@ -58,7 +58,6 @@ "@cloudflare/workflows-shared": "workspace:*", "@types/command-exists": "^1.2.0", "@types/node": "catalog:default", - "@types/signal-exit": "^3.0.1", "@vitest/ui": "catalog:default", "cloudflare": "^5.2.0", "command-exists": "catalog:default", diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index c27d279ba3..8cdcd7bbc3 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -4169,6 +4169,42 @@ const validateD1Binding: ValidatorFn = (diagnostics, field, value) => { isValid = false; } + if (!isOptionalProperty(value, "database_name", "string")) { + diagnostics.errors.push( + `"${field}" bindings should, optionally, have a string "database_name" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + + if (!isOptionalProperty(value, "migrations_dir", "string")) { + diagnostics.errors.push( + `"${field}" bindings should, optionally, have a string "migrations_dir" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + + if (!isOptionalProperty(value, "migrations_table", "string")) { + diagnostics.errors.push( + `"${field}" bindings should, optionally, have a string "migrations_table" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + + if (!isOptionalProperty(value, "database_internal_env", "string")) { + diagnostics.errors.push( + `"${field}" bindings should, optionally, have a string "database_internal_env" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + validateAdditionalProperties(diagnostics, field, Object.keys(value), [ "binding", "database_id", diff --git a/packages/workers-utils/src/wrangler-tmp-dir.ts b/packages/workers-utils/src/wrangler-tmp-dir.ts index 83b1e4d730..cd17ed394f 100644 --- a/packages/workers-utils/src/wrangler-tmp-dir.ts +++ b/packages/workers-utils/src/wrangler-tmp-dir.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import onExit from "signal-exit"; +import { onExit } from "signal-exit"; import { removeDirSync } from "./fs-helpers"; /** diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index dfbc2ae185..c093e75fc3 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -4210,6 +4210,106 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.hasWarnings()).toBe(false); expect(diagnostics.hasErrors()).toBe(false); }); + + it("should error if D1 database database_name has incorrect type", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + d1_databases: [ + { + binding: "DB", + database_name: true, + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "d1_databases[0]" bindings should, optionally, have a string "database_name" field but got {"binding":"DB","database_name":true}." + `); + }); + + it("should error if D1 database migrations_dir has incorrect type", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + d1_databases: [ + { + binding: "DB", + migrations_dir: 123, + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "d1_databases[0]" bindings should, optionally, have a string "migrations_dir" field but got {"binding":"DB","migrations_dir":123}." + `); + }); + + it("should error if D1 database migrations_table has incorrect type", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + d1_databases: [ + { + binding: "DB", + migrations_table: {}, + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "d1_databases[0]" bindings should, optionally, have a string "migrations_table" field but got {"binding":"DB","migrations_table":{}}." + `); + }); + + it("should error if D1 database database_internal_env has incorrect type", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + d1_databases: [ + { + binding: "DB", + database_internal_env: false, + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "d1_databases[0]" bindings should, optionally, have a string "database_internal_env" field but got {"binding":"DB","database_internal_env":false}." + `); + }); }); describe("[hyperdrive]", () => { diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 13b5e1f6ef..b26dea6f8a 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -119,7 +119,6 @@ "@types/prompts": "^2.0.14", "@types/resolve": "^1.20.6", "@types/shell-quote": "^1.7.2", - "@types/signal-exit": "^3.0.1", "@types/supports-color": "^8.1.1", "@types/ws": "^8.5.7", "@types/yargs": "^17.0.22", diff --git a/packages/wrangler/src/pages/deployment-tails.ts b/packages/wrangler/src/pages/deployment-tails.ts index 729043b5ca..ebcd4703f8 100644 --- a/packages/wrangler/src/pages/deployment-tails.ts +++ b/packages/wrangler/src/pages/deployment-tails.ts @@ -4,7 +4,7 @@ import { FatalError, UserError, } from "@cloudflare/workers-utils"; -import onExit from "signal-exit"; +import { onExit } from "signal-exit"; import { fetchResult } from "../cfetch"; import { readConfig } from "../config"; import { getConfigCache } from "../config-cache"; @@ -280,7 +280,7 @@ export const pagesDeploymentTailCommand = createCommand({ }; })(); - onExit(onCloseTail); + onExit(() => void onCloseTail()); tail.on("message", (data) => { if (format === "pretty") { diff --git a/packages/wrangler/src/utils/log-file.ts b/packages/wrangler/src/utils/log-file.ts index f00504ca4b..b742e93806 100644 --- a/packages/wrangler/src/utils/log-file.ts +++ b/packages/wrangler/src/utils/log-file.ts @@ -7,7 +7,7 @@ import { getGlobalConfigPath, } from "@cloudflare/workers-utils"; import { Mutex } from "miniflare"; -import onExit from "signal-exit"; +import { onExit } from "signal-exit"; import { logger } from "../logger"; import { ensureDirectoryExists } from "./filesystem"; import type { LoggerLevel } from "../logger"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c96276eafc..dab79cffa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,8 +61,8 @@ catalogs: specifier: 1.60.0 version: 1.60.0 signal-exit: - specifier: 3.0.7 - version: 3.0.7 + specifier: 4.1.0 + version: 4.1.0 smol-toml: specifier: 1.5.2 version: 1.5.2 @@ -4182,9 +4182,6 @@ importers: '@types/node': specifier: 22.15.17 version: 22.15.17 - '@types/signal-exit': - specifier: ^3.0.1 - version: 3.0.1 '@vitest/ui': specifier: catalog:default version: 4.1.0(vitest@4.1.0) @@ -4205,7 +4202,7 @@ importers: version: 3.2.0 signal-exit: specifier: catalog:default - version: 3.0.7 + version: 4.1.0 smol-toml: specifier: catalog:default version: 1.5.2 @@ -4391,9 +4388,6 @@ importers: '@types/shell-quote': specifier: ^1.7.2 version: 1.7.2 - '@types/signal-exit': - specifier: ^3.0.1 - version: 3.0.1 '@types/supports-color': specifier: ^8.1.1 version: 8.1.1 @@ -4531,7 +4525,7 @@ importers: version: 1.8.1 signal-exit: specifier: catalog:default - version: 3.0.7 + version: 4.1.0 smol-toml: specifier: catalog:default version: 1.5.2 @@ -9382,9 +9376,6 @@ packages: '@types/shell-quote@1.7.2': resolution: {integrity: sha512-p3SZxGp6LXB6RPdMpJmquKjaxQlN/ijyBLTKGPg9IJK6J2g2sJsMmtXP9kNR+Axxi6MDKN2e/76HCOUmvkIpcw==} - '@types/signal-exit@3.0.1': - resolution: {integrity: sha512-OSitN9PP9E/c4tlt1Qdj3CAz5uHD9Da5rhUqlaKyQRCX1T7Zdpbk6YdeZbR2eiE2ce+NMBgVnMxGqpaPSNQDUQ==} - '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -20408,8 +20399,6 @@ snapshots: '@types/shell-quote@1.7.2': {} - '@types/signal-exit@3.0.1': {} - '@types/stack-utils@2.0.3': {} '@types/statuses@2.0.6': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 57d033aa7a..72c7bcb48b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -130,7 +130,7 @@ catalog: "capnweb": "0.5.0" "ci-info": "4.4.0" "open": "11.0.0" - "signal-exit": "3.0.7" + "signal-exit": "4.1.0" # CAUTION: Most usage of @cloudflare/vitest-pool-workers in this monorepo should use workspace:* instead of this catalog version # However, some packages (pages-shared, workers-shared, etc...) need to be tested using vitest-pool-workers but are themselves # ultimately included in vitest-pool-workers (through Wrangler), causing a circular dependency.