Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/lucky-donkeys-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@cloudflare/vitest-plugin": minor
---

Add an experimental `newConfig` option for loading the Worker's configuration from `cloudflare.config.ts`

Projects that have migrated to the new TypeScript configuration format had no way to run their Vitest suite against their real bindings, since there was no Wrangler configuration file left to point `wrangler.configPath` at. This adds the missing option, modelled on `@cloudflare/vite-plugin`'s `experimental.newConfig`:

```ts
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineProject } from "vitest/config";

export default defineProject({
plugins: [cloudflareTest({ experimental: { newConfig: true } })],
});
```

`newConfig: true` loads `cloudflare.config.ts` from the project root; pass `{ configPath: "..." }` to load it from elsewhere. Config functions are called with `ctx.mode` set to Vite's mode, which defaults to `"test"` and can be overridden with `--mode`. `experimental.newConfig` cannot be combined with `wrangler`.

This is experimental and may change without a major version bump. Wrangler environments, `wrangler.config.ts` tooling configuration, and type generation are not supported yet.
7 changes: 7 additions & 0 deletions .changeset/miniflare-sighup-orphan-workerd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

Shut down `workerd` when Miniflare is terminated with `SIGHUP`

On `SIGHUP`, Miniflare now stops `workerd` and removes its temporary directory instead of leaving them behind. Previously only `SIGINT` and `SIGTERM` were handled, so tools that embed Miniflare, such as `@cloudflare/vitest-pool-workers` and `@cloudflare/vite-plugin`, could leave a stray process and directory behind on each run.
26 changes: 26 additions & 0 deletions .changeset/strong-ravens-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"miniflare": minor
---

Add experimental shared local storage, letting several Miniflare instances read and write one set of local resources

Each instance previously kept its own copy of local state, so two dev sessions pointed at the same KV namespace or D1 database could not see each other's writes. Instances that opt in now elect a single storage owner through the dev registry and route storage through it, so resources with the same ID resolve to the same data.

Opt in with `unsafeEnableSharedStorage`, which requires three paths to be set:

```js
new Miniflare({
unsafeEnableSharedStorage: true,
// Shared between instances: resources that participate in sharing live here
resourcePersistencePath: "/path/to/shared/state",
// Per project: resources that cannot be shared keep their own state here
isolatedResourcePersistencePath: "/path/to/project/state",
// Instances elect the storage owner through the dev registry
unsafeDevRegistryPath: "/path/to/registry",
// ...
});
```

KV, D1, R2, Rate Limits, and Secrets Store participate in sharing. Cache, Durable Objects, Workflows, observability, and Hello World storage do not yet, and stay instance-local under `isolatedResourcePersistencePath`, keeping their state across restarts without concurrent access to the shared root.

This is experimental and the `unsafe`-prefixed options may change without a major version bump.
7 changes: 7 additions & 0 deletions .changeset/tidy-spoons-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/config": patch
---

Fix declaration emit for values returned by `defineSettings`

Projects can now export a `defineSettings()` result while generating TypeScript declarations without encountering TS4023.
9 changes: 9 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# ✅ new-config

This Worker is configured with a `cloudflare.config.ts` file instead of a Wrangler configuration file, using the experimental `experimental.newConfig` pool option. Bindings, the compatibility date and the entrypoint all come from that file.

Config functions receive `ctx.mode` set to Vite's mode, which defaults to `"test"` under Vitest and can be overridden with `--mode`.

| Test | Overview |
| ----------------------------------- | -------------------------------------------------------------------- |
| [index.test.ts](test/index.test.ts) | Bindings, `SELF` dispatch and unit tests against a new-config Worker |
13 changes: 13 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/cloudflare.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { bindings, defineWorker } from "wrangler/experimental-config";
import * as entrypoint from "./src/index.ts" with { type: "cf-worker" };

export default defineWorker({
name: "vitest-plugin-new-config",
entrypoint,
compatibilityDate: "2025-12-02",
compatibilityFlags: ["nodejs_compat"],
env: {
MY_TEXT: bindings.text("from cloudflare.config.ts"),
MY_KV: bindings.kv({ id: "vitest-plugin-new-config-kv" }),
},
});
12 changes: 12 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);

if (url.pathname === "/kv") {
await env.MY_KV.put("key", "value");
return new Response(await env.MY_KV.get("key"));
}

return new Response(env.MY_TEXT);
},
} satisfies ExportedHandler<Env>;
13 changes: 13 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.workerd.json",
"compilerOptions": {
// `cloudflare.config.ts` references the entrypoint by path, including its
// `.ts` extension
"allowImportingTsExtensions": true
},
"include": [
"./**/*.ts",
"../cloudflare.config.ts",
"../worker-configuration.d.ts"
]
}
36 changes: 36 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
createExecutionContext,
env,
SELF,
waitOnExecutionContext,
} from "cloudflare:test";
import { it } from "vitest";
import worker from "../src/index";

it("exposes bindings declared in cloudflare.config.ts", ({ expect }) => {
expect(env.MY_TEXT).toBe("from cloudflare.config.ts");
});

it("dispatches to the entrypoint declared in cloudflare.config.ts", async ({
expect,
}) => {
const response = await SELF.fetch("https://example.com");
expect(await response.text()).toBe("from cloudflare.config.ts");
});

it("reads and writes the KV namespace", async ({ expect }) => {
const response = await SELF.fetch("https://example.com/kv");
expect(await response.text()).toBe("value");
expect(await env.MY_KV.get("key")).toBe("value");
});

it("can unit test the handler directly", async ({ expect }) => {
const ctx = createExecutionContext();
const response = await worker.fetch(
new Request("https://example.com"),
env,
ctx
);
await waitOnExecutionContext(ctx);
expect(await response.text()).toBe("from cloudflare.config.ts");
});
9 changes: 9 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.workerd-test.json",
"compilerOptions": {
// `worker-configuration.d.ts` infers `Env` from `cloudflare.config.ts`,
// which references the entrypoint by path, including its `.ts` extension
"allowImportingTsExtensions": true
},
"include": ["./**/*.ts", "../src/index.ts", "../worker-configuration.d.ts"]
}
4 changes: 4 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.node.json",
"include": ["vitest.config.ts"]
}
18 changes: 18 additions & 0 deletions fixtures/vitest-plugin-examples/new-config/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineProject, mergeConfig } from "vitest/config";
import configShared from "../../../vitest.shared";

export default mergeConfig(
configShared,
defineProject({
plugins: [
cloudflareTest({
experimental: {
// Load the Worker's configuration from `cloudflare.config.ts`
// instead of a Wrangler configuration file
newConfig: true,
},
}),
],
})
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* eslint-disable */
// Generated by @cloudflare/config
type __WorkerConfig = import("wrangler/experimental-config").UnwrapConfig<typeof import("./cloudflare.config").default>;
type __Env = import("wrangler/experimental-config").InferEnv<__WorkerConfig>;

declare namespace Cloudflare {
interface GlobalProps {
mainModule: import("wrangler/experimental-config").InferMainModule<__WorkerConfig>;
durableNamespaces: import("wrangler/experimental-config").InferDurableNamespaces<__WorkerConfig>;
}
interface Env extends __Env {}
}
interface Env extends Cloudflare.Env {}
5 changes: 4 additions & 1 deletion packages/config/src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,8 @@ export type {
WorkerConfigInput,
} from "./worker-definition";
export { defineWorker } from "./worker-definition";
export type { SettingsConfigInput } from "./settings-definition";
export type {
SettingsConfigInput,
SettingsDefinition,
} from "./settings-definition";
export { defineSettings } from "./settings-definition";
14 changes: 13 additions & 1 deletion packages/config/src/settings-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,22 @@ import type { SettingsConfig } from "./types";
*/
export type SettingsConfigInput = Omit<SettingsConfig, "type">;

/**
* A settings definition created by {@link defineSettings}.
*/
export interface SettingsDefinition {
[DEFINITION]: {
config: ConfigInput<SettingsConfigInput>;
type: "settings";
};
}

/**
* Declare shared settings.
* Authored as a named `settings` export.
*/
export function defineSettings(config: ConfigInput<SettingsConfigInput>) {
export function defineSettings(
config: ConfigInput<SettingsConfigInput>
): SettingsDefinition {
return { [DEFINITION]: { config, type: "settings" } };
}
1 change: 1 addition & 0 deletions packages/miniflare/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Local dev simulator for Cloudflare Workers, powered by workerd runtime. Main cla
- `src/workers/core/dev-registry-proxy.worker.ts` — Proxy worker for cross-process service bindings via debug port RPC
- `src/workers/core/dev-registry-proxy-shared.worker.ts` — Shared proxy logic (registry Map, DO proxy class, tail serializers)
- `src/shared/dev-registry.ts` — Filesystem-based worker registry (chokidar watch, heartbeat, stale cleanup)
- `src/shared/persist-root-lock.ts` — Token-safe startup serialisation for shared persistent storage
- `src/shared/DEV_REGISTRY.md` — Full architecture doc for the dev registry
- `src/runtime/config/generated/workerd.ts` — Generated workerd Cap'n Proto config types
- `test/` — Tests (`.spec.ts` naming, NOT `.test.ts`)
Expand Down
61 changes: 59 additions & 2 deletions packages/miniflare/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,11 +701,28 @@ export const InstanceOptionsSchema = z.strictObject({
stripDisablePrettyError: z.boolean().default(true),

// Persistence
/** Root directory for persisted local resource state; relative to cwd if not absolute. */
/**
* Root directory for persisted local resource state; relative to cwd if not
* absolute. When `unsafeEnableSharedStorage` is set this is canonicalised to
* an absolute real path before use, so every instance sharing the directory
* derives the same ownership scope.
*/
resourcePersistencePath: z.string().optional(),
/**
* Root for resources that cannot participate in shared storage. Belongs at
* the project level -- each project keeps its own copy of this state rather
* than partitioning it under the shared resource root.
*
* Required when `unsafeEnableSharedStorage` is set. Parsing resolves this to
* the effective isolated root, falling back to `resourcePersistencePath`
* when shared storage is off, so readers never need to decide themselves.
*/
isolatedResourcePersistencePath: z.string().optional(),
/** Project temp directory for plugin files; relative to cwd if not absolute. */
resourceTmpPath: z.string().optional(),

unsafeEnableSharedStorage: z.boolean().optional(),

containerEngine: z
.union([
z.string(),
Expand Down Expand Up @@ -769,7 +786,47 @@ export type ParsedLegacyConfig = NonNullable<ParsedWorkerOptions["legacy"]>;

export const MiniflareOptionsSchema = InstanceOptionsSchema.extend({
workers: z.array(WorkerOptionsSchema),
});
})
.superRefine((options, ctx) => {
if (!options.unsafeEnableSharedStorage) {
return;
}
if (!options.resourcePersistencePath?.trim()) {
ctx.addIssue({
code: "custom",
path: ["resourcePersistencePath"],
message:
"Shared storage requires `resourcePersistencePath` to be set to the directory instances should share.",
});
}
if (!options.isolatedResourcePersistencePath?.trim()) {
ctx.addIssue({
code: "custom",
path: ["isolatedResourcePersistencePath"],
message:
"Shared storage requires `isolatedResourcePersistencePath` to be set to a per-project directory, for resources that cannot be shared.",
});
}
if (!options.unsafeDevRegistryPath?.trim()) {
ctx.addIssue({
code: "custom",
path: ["unsafeDevRegistryPath"],
message:
"Shared storage requires `unsafeDevRegistryPath` to be set, as instances elect a storage owner through the dev registry.",
});
}
})
.transform((options) => ({
...options,
// Resolve the effective isolated root once, here, so that everything
// downstream reads a single field that is always the path to persist to.
// Without shared storage nothing is shared, so every resource is isolated
// and the configured resource root is the isolated root. Validation above
// has already required an explicit isolated root when sharing is enabled.
isolatedResourcePersistencePath: options.unsafeEnableSharedStorage
? options.isolatedResourcePersistencePath
: options.resourcePersistencePath,
}));

export type MiniflareOptions = z.input<typeof MiniflareOptionsSchema>;

Expand Down
1 change: 1 addition & 0 deletions packages/miniflare/src/config/v4-convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function convertSharedOptions(options: ParsedV4MiniflareOptions) {
unsafeInspectDurableObjects: options.unsafeInspectDurableObjects,
logRequests: options.logRequests,
resourcePersistencePath: options.resourcePersistencePath,
isolatedResourcePersistencePath: options.isolatedResourcePersistencePath,
resourceTmpPath: options.resourceTmpPath,
stripDisablePrettyError: options.stripDisablePrettyError,
telemetry: options.telemetry,
Expand Down
3 changes: 3 additions & 0 deletions packages/miniflare/src/config/v4-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,8 @@ export const V4SharedOptionsSchema = z.object({
logRequests: z.boolean().default(true),
/** Root directory for persisted local resource state; relative to cwd if not absolute. */
resourcePersistencePath: z.string().optional(),
/** Per-instance root for resources that cannot participate in shared storage. */
isolatedResourcePersistencePath: z.string().optional(),
/** Project temp directory for plugin files; relative to cwd if not absolute. */
resourceTmpPath: z.string().optional(),
stripDisablePrettyError: z.boolean().default(true),
Expand Down Expand Up @@ -946,6 +948,7 @@ export type V4SharedOptions = {
unsafeInspectDurableObjects?: boolean;
logRequests?: boolean;
resourcePersistencePath?: string;
isolatedResourcePersistencePath?: string;
resourceTmpPath?: string;
stripDisablePrettyError?: boolean;
telemetry?: { enabled?: boolean; deviceId?: string };
Expand Down
14 changes: 14 additions & 0 deletions packages/miniflare/src/exit-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ function onSignalTerm(): void {
process.exit(128 + 15);
}

function onSignalHup(): void {
runCallbacks();
// eslint-disable-next-line unicorn/no-process-exit -- intentional: replicate default SIGHUP behavior
process.exit(128 + 1);
}

function onMessage(message: unknown): void {
if (message === "shutdown") {
runCallbacks();
Expand All @@ -57,6 +63,13 @@ function addListeners(): void {
process.on("exit", onExit);
process.on("SIGINT", onSignalInt);
process.on("SIGTERM", onSignalTerm);
// Without this, `SIGHUP` exits without running any handler, so `dispose()`
// never reaches the `SIGKILL` that stops `workerd` and it is left reparented
// to init. Matters most when Miniflare is embedded rather than run under
// `wrangler dev`, which leaves `workerd` in the caller's process group where
// the signal reaches it anyway.
// See https://github.com/cloudflare/workers-sdk/issues/9193.
process.on("SIGHUP", onSignalHup);
// Only listen for IPC "shutdown" messages (PM2 support) when the process
// actually has an IPC channel. Even without this guard the listener is
// harmless when there is no channel, but being explicit avoids any
Expand All @@ -71,6 +84,7 @@ function removeListeners(): void {
process.removeListener("exit", onExit);
process.removeListener("SIGINT", onSignalInt);
process.removeListener("SIGTERM", onSignalTerm);
process.removeListener("SIGHUP", onSignalHup);
process.removeListener("message", onMessage);
}

Expand Down
Loading
Loading