Skip to content
9 changes: 9 additions & 0 deletions .changeset/miniflare-assets-missing-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"miniflare": patch
---

Gracefully handle a missing assets directory by starting with zero assets

Previously, configuring Miniflare with an `assets.directory` that did not exist on disk would cause the asset services to fail to start. This is a common situation during `wrangler dev` when the assets directory is a build output that hasn't been generated yet.

Now, when the configured assets directory does not exist, Miniflare creates an empty temporary directory and starts the asset services with zero assets. Once the real directory is created and `setOptions()` is called (e.g. triggered by the file watcher), Miniflare reloads and begins serving the actual assets.
9 changes: 9 additions & 0 deletions .changeset/wrangler-assets-missing-dir-local-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"wrangler": patch
---

Allow `getPlatformProxy` and `unstable_getMiniflareWorkerOptions` to start when the assets directory does not exist yet

Previously, `getPlatformProxy` would catch and swallow `NonExistentAssetsDirError` internally when the configured assets directory was absent on disk. This has been refactored so that the directory-existence check is skipped entirely for `getPlatformProxy` and `unstable_getMiniflareWorkerOptions`, since these APIs are typically used at dev time in frameworks where the assets directory is a build output that may not exist yet.

`wrangler dev`, `wrangler deploy`, `wrangler versions upload`, and `wrangler triggers deploy` continue to require the assets directory to exist when specified.
45 changes: 39 additions & 6 deletions packages/miniflare/src/plugins/assets/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path, { join } from "node:path";
import {
CONTENT_HASH_OFFSET,
Expand Down Expand Up @@ -52,6 +53,11 @@ import type { Logger } from "@cloudflare/workers-shared";
import type { AssetConfig } from "@cloudflare/workers-shared/utils/types";
import type { z } from "zod";

// Cache of temp directories created for missing asset directories, keyed by
// the configured assets directory path. Prevents accumulating orphaned temp
// directories when getServices is called repeatedly
const tempDirCache = new Map<string, string>();

export const ASSETS_PLUGIN: Plugin<typeof AssetsOptionsSchema> = {
options: AssetsOptionsSchema,
async getBindings(options: z.infer<typeof AssetsOptionsSchema>) {
Expand Down Expand Up @@ -83,22 +89,49 @@ export const ASSETS_PLUGIN: Plugin<typeof AssetsOptionsSchema> = {
return [];
}

let assetDirectory = options.assets.directory;
const directoryStats = await fs.stat(assetDirectory).catch((err) => {
if (err?.code === "ENOENT") {
Comment thread
dario-piotrowicz marked this conversation as resolved.
return undefined;
}
throw err;
});
if (!directoryStats) {
// If the assets directory doesn't exist yet (e.g. the build output
// hasn't been generated), create an empty temp directory so that the
// asset services can still start up with zero assets.
// Reuse a previously created temp directory for this path to avoid
// accumulating orphaned temp directories on repeated calls.
const cached = tempDirCache.get(assetDirectory);
const cachedExists = cached
? await fs.stat(cached).catch(() => undefined)
: undefined;
if (cached && cachedExists) {
assetDirectory = cached;
} else {
const originalDir = assetDirectory;
assetDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), "miniflare-assets-")
);
tempDirCache.set(originalDir, assetDirectory);
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

const storageServiceName = `${ASSETS_PLUGIN_NAME}:storage`;
const storageService: Service = {
name: storageServiceName,
disk: {
path: options.assets.directory,
path: assetDirectory,
writable: true,
allowDotfiles: true,
},
};

const { encodedAssetManifest, assetsReverseMap } = await buildAssetManifest(
options.assets.directory
);
const { encodedAssetManifest, assetsReverseMap } =
await buildAssetManifest(assetDirectory);

const redirectsFile = join(options.assets.directory, REDIRECTS_FILENAME);
const headersFile = join(options.assets.directory, HEADERS_FILENAME);
const redirectsFile = join(assetDirectory, REDIRECTS_FILENAME);
const headersFile = join(assetDirectory, HEADERS_FILENAME);

const redirectsContents = maybeGetFile(redirectsFile);
const headersContents = maybeGetFile(headersFile);
Expand Down
100 changes: 100 additions & 0 deletions packages/miniflare/test/plugins/assets/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import fs from "node:fs/promises";
import path from "node:path";
import { Miniflare } from "miniflare";
import { test } from "vitest";
import { useDispose, useTmp } from "../../test-shared";

// Minimal worker script. When assets are configured, all incoming `dispatchFetch`
// requests are automatically routed through the assets router service, which
// either serves a matched asset (200) or returns 404 (because `has_user_worker`
// defaults to false, so unmatched paths go straight to the asset worker's 404).
const WORKER_SCRIPT = `export default {
async fetch(request, env) {
return new Response("from user worker");
}
}`;

function makeOptions(directory: string) {
return {
modules: true,
script: WORKER_SCRIPT,
compatibilityDate: "2026-04-29",
assets: { directory },
};
}

test("starts without error when assets directory does not exist", async ({
expect,
}) => {
const tmp = await useTmp();
// Create a path that does not exist on disk
const nonExistentDir = path.join(tmp, "does-not-exist");

// Should not throw even though the directory is absent
const mf = new Miniflare(makeOptions(nonExistentDir));
useDispose(mf);

// Empty manifest → asset not found → 404
const res = await mf.dispatchFetch("http://example.com/test.txt");
expect(res.status).toBe(404);
await res.arrayBuffer(); // consume body to avoid leaks
});

test("starts without error when assets directory is empty", async ({
expect,
}) => {
const tmp = await useTmp();

const mf = new Miniflare(makeOptions(tmp));
useDispose(mf);

const res = await mf.dispatchFetch("http://example.com/test.txt");
expect(res.status).toBe(404);
await res.arrayBuffer();
});

test("serves files from assets directory", async ({ expect }) => {
const tmp = await useTmp();
await fs.writeFile(path.join(tmp, "test.txt"), "hello from asset");

const mf = new Miniflare(makeOptions(tmp));
useDispose(mf);

const res = await mf.dispatchFetch("http://example.com/test.txt");
expect(res.status).toBe(200);
expect(await res.text()).toBe("hello from asset");
});

// ─── Watch / reload behaviour ────────────────────────────────────────────────

// This test simulates what happens during `wrangler dev` when the assets
// directory does not exist at startup but it is created afterwards
test("serves new assets after setOptions() once the directory is created", async ({
expect,
}) => {
const tmp = await useTmp();
// The assets directory does not exist yet (build hasn't run)
const assetsDir = path.join(tmp, "dist");

const mf = new Miniflare(makeOptions(assetsDir));
useDispose(mf);

// Initially no assets → 404
const res1 = await mf.dispatchFetch("http://example.com/index.html");
expect(res1.status).toBe(404);
await res1.arrayBuffer();

// Create assets directory
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "index.html"), "<h1>Hello!</h1>");

// Simulate wrangler's reaction to chokidar detecting the new directory:
// it calls setOptions() which re-invokes getServices() on all plugins,
// re-walks the assets directory, and rebuilds the manifest.
await mf.setOptions(makeOptions(assetsDir));

// The asset should now be served
const res2 = await mf.dispatchFetch("http://example.com/index.html");
expect(res2.status).toBe(200);
expect(await res2.text()).toContain("<h1>Hello!</h1>");
});
172 changes: 172 additions & 0 deletions packages/wrangler/src/__tests__/assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, it } from "vitest";
import {
NonDirectoryAssetsDirError,
NonExistentAssetsDirError,
getAssetsOptions,
} from "../assets";
import { runInTempDir } from "./helpers/run-in-tmp";
import type { Config } from "@cloudflare/workers-utils";

/**
* Creates a minimal Config object sufficient for `getAssetsOptions`.
* Only the fields actually read by the function need to be populated.
*/
function makeConfig(
overrides: Partial<{
assets: { directory?: string; binding?: string };
main: string;
configPath: string;
}> = {}
): Config {
return {
assets: undefined,
main: undefined,
configPath: undefined,
compatibility_date: "2026-04-29",
compatibility_flags: [],
...overrides,
} as unknown as Config;
}

describe("getAssetsOptions", () => {
runInTempDir();

describe("validateDirectoryExistence: true (default — deploy path)", () => {
it("throws NonExistentAssetsDirError when the --assets directory does not exist", ({
expect,
}) => {
expect(() =>
getAssetsOptions({
args: { assets: "dist" },
config: makeConfig(),
validateDirectoryExistence: true,
})
).toThrow(NonExistentAssetsDirError);
});

it("throws with a message referencing the CLI flag when --assets is used", ({
expect,
}) => {
expect(() =>
getAssetsOptions({
args: { assets: "dist" },
config: makeConfig(),
validateDirectoryExistence: true,
})
).toThrow(
/The directory specified by the "--assets" command line argument does not exist/
);
});

it("throws with a message referencing the config file when assets.directory is used", ({
expect,
}) => {
expect(() =>
getAssetsOptions({
args: { assets: undefined },
config: makeConfig({ assets: { directory: "dist" } }),
validateDirectoryExistence: true,
})
).toThrow(
/The directory specified by the "assets.directory" field in your configuration file does not exist/
);
});

it("throws NonDirectoryAssetsDirError when the path points to a file, not a directory", ({
expect,
}) => {
fs.writeFileSync("not-a-dir.txt", "");
expect(() =>
getAssetsOptions({
args: { assets: "not-a-dir.txt" },
config: makeConfig(),
validateDirectoryExistence: true,
})
).toThrow(NonDirectoryAssetsDirError);
});
});

describe("validateDirectoryExistence: false (getPlatformProxy / unstable_getMiniflareWorkerOptions path)", () => {
it("does NOT throw when the assets directory does not exist", ({
expect,
}) => {
expect(() =>
getAssetsOptions({
args: { assets: "dist" },
config: makeConfig(),
validateDirectoryExistence: false,
})
).not.toThrow();
});

it("returns a valid AssetsOptions object even when the directory is absent", ({
expect,
}) => {
const result = getAssetsOptions({
args: { assets: "dist" },
config: makeConfig(),
validateDirectoryExistence: false,
});

expect(result).toBeDefined();
expect(result?.directory).toBe(path.resolve(process.cwd(), "dist"));
// No _redirects / _headers since the directory doesn't exist
expect(result?._redirects).toBeUndefined();
expect(result?._headers).toBeUndefined();
});

it("still throws NonDirectoryAssetsDirError when the path points to a file", ({
expect,
}) => {
fs.writeFileSync("not-a-dir.txt", "");
expect(() =>
getAssetsOptions({
args: { assets: "not-a-dir.txt" },
config: makeConfig(),
validateDirectoryExistence: false,
})
).toThrow(NonDirectoryAssetsDirError);
});

it("returns correct options when the directory exists and has files", ({
expect,
}) => {
fs.mkdirSync("dist");
fs.writeFileSync(path.join("dist", "_redirects"), "/old /new 301");

const result = getAssetsOptions({
args: { assets: "dist" },
config: makeConfig(),
validateDirectoryExistence: false,
});

expect(result?.directory).toBe(path.resolve(process.cwd(), "dist"));
expect(result?._redirects).toContain("/old /new 301");
});

it("works with assets from config rather than the CLI flag", ({
expect,
}) => {
const result = getAssetsOptions({
args: { assets: undefined },
config: makeConfig({ assets: { directory: "nonexistent-dir" } }),
validateDirectoryExistence: false,
});

expect(result).toBeDefined();
expect(result?.directory).toContain("nonexistent-dir");
});

it("returns undefined when no assets are configured", ({ expect }) => {
const result = getAssetsOptions({
args: { assets: undefined },
config: makeConfig(),
validateDirectoryExistence: false,
});

expect(result).toBeUndefined();
});
});
});
Loading
Loading