From 23a365a7e578ecb6735c1f05a204f5bf236b24f6 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:28:20 +0000 Subject: [PATCH 1/3] Add dev support for experimental `secrets` property (#12701) --- .changeset/quiet-queens-build.md | 5 + .../vite-plugin-cloudflare/src/dev-vars.ts | 12 +- .../src/plugins/output-config.ts | 2 +- .../src/__tests__/config/loadDotEnv.test.ts | 4 +- packages/wrangler/src/__tests__/dev.test.ts | 201 ++++++++++++++++-- packages/wrangler/src/config/dot-env.ts | 4 +- packages/wrangler/src/dev.ts | 7 +- packages/wrangler/src/dev/dev-vars.ts | 62 ++++-- 8 files changed, 247 insertions(+), 50 deletions(-) create mode 100644 .changeset/quiet-queens-build.md diff --git a/.changeset/quiet-queens-build.md b/.changeset/quiet-queens-build.md new file mode 100644 index 000000000000..0a8f58fbac17 --- /dev/null +++ b/.changeset/quiet-queens-build.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Add dev support for experimental `secrets` property. diff --git a/packages/vite-plugin-cloudflare/src/dev-vars.ts b/packages/vite-plugin-cloudflare/src/dev-vars.ts index 290951abfc53..ad7d3e7ed960 100644 --- a/packages/vite-plugin-cloudflare/src/dev-vars.ts +++ b/packages/vite-plugin-cloudflare/src/dev-vars.ts @@ -4,21 +4,25 @@ import type { AssetsOnlyResolvedConfig, WorkersResolvedConfig, } from "./plugin-config"; +import type { Unstable_Config } from "wrangler"; /** * Gets any variables with which to augment the Worker config in preview mode. * - * Calls `unstable_getVarsForDev` with the current Cloudflare environment to get local dev variables from the `.dev.vars` and `.env` files. + * Calls `unstable_getVarsForDev` with the current Cloudflare environment to get local dev variables from the .dev.vars/.env/process.env. + * When `secrets` is defined in the Worker config, only declared secrets are loaded. */ export function getLocalDevVarsForPreview( - configPath: string | undefined, + config: Unstable_Config, cloudflareEnv: string | undefined ): string | undefined { const dotDevDotVars = wrangler.unstable_getVarsForDev( - configPath, + config.configPath, undefined, // We don't currently support setting a list of custom `.env` files. {}, // Don't pass actual vars since these will be loaded from the wrangler.json. - cloudflareEnv + cloudflareEnv, + false, + config.secrets ); const dotDevDotVarsEntries = Array.from(Object.entries(dotDevDotVars)); if (dotDevDotVarsEntries.length > 0) { diff --git a/packages/vite-plugin-cloudflare/src/plugins/output-config.ts b/packages/vite-plugin-cloudflare/src/plugins/output-config.ts index 40012ea751b1..1688b86467fe 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/output-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/output-config.ts @@ -74,7 +74,7 @@ export const outputConfigPlugin = createPlugin("output-config", (ctx) => { if (inputWorkerConfig.configPath) { const localDevVars = getLocalDevVarsForPreview( - inputWorkerConfig.configPath, + inputWorkerConfig, ctx.resolvedPluginConfig.cloudflareEnv ); // Save a .dev.vars file to the worker's build output directory if there are local dev vars, so that it will be then detected by `vite preview`. diff --git a/packages/wrangler/src/__tests__/config/loadDotEnv.test.ts b/packages/wrangler/src/__tests__/config/loadDotEnv.test.ts index c7ec0a8f8bd9..7160b4e47edc 100644 --- a/packages/wrangler/src/__tests__/config/loadDotEnv.test.ts +++ b/packages/wrangler/src/__tests__/config/loadDotEnv.test.ts @@ -36,8 +36,8 @@ describe("loadDotEnv()", () => { expect(result).toEqual({ FOO: "qux", BAZ: "qux" }); expect(std.out).toMatchInlineSnapshot(` - "Using vars defined in .env - Using vars defined in .env.local" + "Using secrets defined in .env + Using secrets defined in .env.local" `); }); diff --git a/packages/wrangler/src/__tests__/dev.test.ts b/packages/wrangler/src/__tests__/dev.test.ts index e4197e257c1a..48ddb7c4c090 100644 --- a/packages/wrangler/src/__tests__/dev.test.ts +++ b/packages/wrangler/src/__tests__/dev.test.ts @@ -1614,7 +1614,7 @@ describe.sequential("wrangler dev", () => { " ⛅️ wrangler x.x.x ────────────────── - Using vars defined in .dev.vars + Using secrets defined in .dev.vars Your Worker has access to the following bindings: Binding Resource Mode env.CONFIG_VAR ("visible value") Environment Variable local @@ -1683,7 +1683,7 @@ describe.sequential("wrangler dev", () => { " ⛅️ wrangler x.x.x ────────────────── - Using vars defined in .dev.vars + Using secrets defined in .dev.vars Your Worker has access to the following bindings: Binding Resource Mode env.VAR_1 ("(hidden)") Environment Variable local @@ -1725,7 +1725,7 @@ describe.sequential("wrangler dev", () => { " ⛅️ wrangler x.x.x ────────────────── - Using vars defined in .dev.vars.custom + Using secrets defined in .dev.vars.custom Your Worker has access to the following bindings: Binding Resource Mode env.CUSTOM_VAR ("(hidden)") Environment Variable local @@ -1735,6 +1735,163 @@ describe.sequential("wrangler dev", () => { }); }); + describe("secrets config", () => { + const processEnv = process.env; + beforeEach(() => (process.env = { ...processEnv })); + afterEach(() => (process.env = processEnv)); + + // --- Resolution: sources in priority order --- + + it("should load declared secrets from .dev.vars", async () => { + fs.writeFileSync("index.js", `export default {};`); + fs.writeFileSync(".dev.vars", `API_KEY=from-dev-dot-vars`); + writeWranglerConfig({ + main: "index.js", + secrets: { required: ["API_KEY"] }, + }); + const config = await runWranglerUntilConfig("dev"); + const bindings = config.bindings ?? {}; + expect(bindings["API_KEY"]).toEqual({ + type: "secret_text", + value: "from-dev-dot-vars", + }); + }); + + it("should load declared secrets from .env when no .dev.vars exists", async () => { + fs.writeFileSync("index.js", `export default {};`); + fs.writeFileSync(".env", `API_KEY=from-dot-env`); + writeWranglerConfig({ + main: "index.js", + secrets: { required: ["API_KEY"] }, + }); + const config = await runWranglerUntilConfig("dev"); + const bindings = config.bindings ?? {}; + expect(bindings["API_KEY"]).toEqual({ + type: "secret_text", + value: "from-dot-env", + }); + }); + + it("should load declared secrets from process.env when not in .dev.vars or .env", async () => { + fs.writeFileSync("index.js", `export default {};`); + // eslint-disable-next-line turbo/no-undeclared-env-vars + process.env.API_KEY = "from-process-env"; + writeWranglerConfig({ + main: "index.js", + secrets: { required: ["API_KEY"] }, + }); + const config = await runWranglerUntilConfig("dev"); + const bindings = config.bindings ?? {}; + expect(bindings["API_KEY"]).toEqual({ + type: "secret_text", + value: "from-process-env", + }); + }); + + it("should prefer .dev.vars over .env for declared secrets", async () => { + fs.writeFileSync("index.js", `export default {};`); + fs.writeFileSync(".dev.vars", `API_KEY=from-dev-dot-vars`); + fs.writeFileSync(".env", `API_KEY=from-dot-env`); + writeWranglerConfig({ + main: "index.js", + secrets: { required: ["API_KEY"] }, + }); + const config = await runWranglerUntilConfig("dev"); + const bindings = config.bindings ?? {}; + expect(bindings["API_KEY"]).toEqual({ + type: "secret_text", + value: "from-dev-dot-vars", + }); + }); + + // --- Validation --- + + it("should warn when a required secret is missing", async () => { + fs.writeFileSync("index.js", `export default {};`); + writeWranglerConfig({ + main: "index.js", + secrets: { required: ["MISSING_KEY"] }, + }); + await runWranglerUntilConfig("dev"); + expect(std.warn).toContain( + "Missing required secrets: MISSING_KEY. Add them to .dev.vars, .env, or set as environment variables." + ); + }); + + // --- Filtering --- + + it("should only include declared secrets from .dev.vars", async () => { + fs.writeFileSync("index.js", `export default {};`); + fs.writeFileSync( + ".dev.vars", + dedent` + DECLARED=from-dev-dot-vars + EXTRA=should-not-appear + ` + ); + writeWranglerConfig({ + main: "index.js", + secrets: { required: ["DECLARED"] }, + }); + const config = await runWranglerUntilConfig("dev"); + const bindings = config.bindings ?? {}; + expect(bindings["DECLARED"]).toEqual({ + type: "secret_text", + value: "from-dev-dot-vars", + }); + expect(bindings["EXTRA"]).toBeUndefined(); + }); + + it("should not treat --var values as secrets", async () => { + fs.writeFileSync("index.js", `export default {};`); + writeWranglerConfig({ + main: "index.js", + secrets: { required: ["MY_SECRET"] }, + }); + const config = await runWranglerUntilConfig( + "dev --var MY_SECRET:from-cli" + ); + const bindings = config.bindings ?? {}; + // --var creates a plain_text binding that overrides via inputBindings + expect(bindings["MY_SECRET"]).toMatchObject({ + type: "plain_text", + }); + // The warning still fires because --var doesn't count as a resolved secret + expect(std.warn).toContain("Missing required secrets: MY_SECRET"); + }); + + // --- Edge cases and backward compat --- + + it("should exclude .dev.vars keys when `secrets` is defined", async () => { + fs.writeFileSync("index.js", `export default {};`); + fs.writeFileSync(".dev.vars", `SOME_KEY=from-dev-dot-vars`); + writeWranglerConfig({ + main: "index.js", + secrets: {}, + }); + const config = await runWranglerUntilConfig("dev"); + const bindings = config.bindings ?? {}; + expect(bindings["SOME_KEY"]).toBeUndefined(); + // No missing secrets warning since no required secrets declared + expect(std.warn).not.toContain("Missing required secrets"); + }); + + it("should still read .dev.vars when secrets is not defined (backward compat)", async () => { + fs.writeFileSync("index.js", `export default {};`); + fs.writeFileSync(".dev.vars", `LEGACY_SECRET=from-dev-dot-vars`); + writeWranglerConfig({ + main: "index.js", + }); + const config = await runWranglerUntilConfig("dev"); + const bindings = config.bindings ?? {}; + expect(bindings["LEGACY_SECRET"]).toEqual({ + type: "secret_text", + value: "from-dev-dot-vars", + }); + expect(std.out).toContain("Using secrets defined in .dev.vars"); + }); + }); + describe(".env in local dev", () => { const processEnv = process.env; beforeEach(() => (process.env = { ...processEnv })); @@ -1780,7 +1937,7 @@ describe.sequential("wrangler dev", () => { function extractUsingVars(stdout: string) { return stdout .split("\n") - .filter((line) => line.startsWith("Using vars")) + .filter((line) => line.startsWith("Using secrets")) .sort() .join("\n"); } @@ -1797,8 +1954,8 @@ describe.sequential("wrangler dev", () => { await runWranglerUntilConfig("dev"); const out = std.out; expect(extractUsingVars(out)).toMatchInlineSnapshot(` - "Using vars defined in .env - Using vars defined in .env.local" + "Using secrets defined in .env + Using secrets defined in .env.local" `); expect(extractBindings(out)).toMatchInlineSnapshot(` "env.__DOT_ENV_LOCAL_DEV_VAR_1 ("(hidden)") Environment Variable local @@ -1819,7 +1976,7 @@ describe.sequential("wrangler dev", () => { await runWranglerUntilConfig("dev"); const out = std.out; expect(extractUsingVars(out)).toMatchInlineSnapshot(` - "Using vars defined in .dev.vars" + "Using secrets defined in .dev.vars" `); expect(extractBindings(out)).toMatchInlineSnapshot(` "env.__DOT_DEV_DOT_VARS_LOCAL_DEV_VAR_1 ("(hidden)") Environment Variable local @@ -1840,10 +1997,10 @@ describe.sequential("wrangler dev", () => { await runWranglerUntilConfig("dev --env custom"); const out = std.out; expect(extractUsingVars(out)).toMatchInlineSnapshot(` - "Using vars defined in .env - Using vars defined in .env.custom - Using vars defined in .env.custom.local - Using vars defined in .env.local" + "Using secrets defined in .env + Using secrets defined in .env.custom + Using secrets defined in .env.custom.local + Using secrets defined in .env.local" `); expect(extractBindings(out)).toMatchInlineSnapshot(` "env.__DOT_ENV_LOCAL_DEV_VAR_1 ("(hidden)") Environment Variable local @@ -1857,8 +2014,8 @@ describe.sequential("wrangler dev", () => { await runWranglerUntilConfig("dev --env noEnv"); const out = std.out; expect(extractUsingVars(out)).toMatchInlineSnapshot(` - "Using vars defined in .env - Using vars defined in .env.local" + "Using secrets defined in .env + Using secrets defined in .env.local" `); expect(extractBindings(out)).toMatchInlineSnapshot(` "env.__DOT_ENV_LOCAL_DEV_VAR_1 ("(hidden)") Environment Variable local @@ -1874,11 +2031,11 @@ describe.sequential("wrangler dev", () => { }); const out = std.out; expect(extractUsingVars(out)).toMatchInlineSnapshot(` - "Using vars defined in .env - Using vars defined in .env.custom - Using vars defined in .env.custom.local - Using vars defined in .env.local - Using vars defined in process.env" + "Using secrets defined in .env + Using secrets defined in .env.custom + Using secrets defined in .env.custom.local + Using secrets defined in .env.local + Using secrets defined in process.env" `); // We could dump out all the bindings but that would be a lot of noise, and also may change between OSes and runs. // Instead, we know that the `CLOUDFLARE_INCLUDE_PROCESS_ENV` variable should be present, so we just check for that. @@ -1908,7 +2065,7 @@ describe.sequential("wrangler dev", () => { await runWranglerUntilConfig("dev --env-file=other/.env"); const out = std.out; expect(extractUsingVars(out)).toMatchInlineSnapshot( - `"Using vars defined in other/.env"` + `"Using secrets defined in other/.env"` ); expect(extractBindings(out)).toMatchInlineSnapshot(` "env.__DOT_ENV_LOCAL_DEV_VAR_2 ("(hidden)") Environment Variable local @@ -1939,8 +2096,8 @@ describe.sequential("wrangler dev", () => { ); const out = std.out; expect(extractUsingVars(out)).toMatchInlineSnapshot(` - "Using vars defined in other/.env - Using vars defined in other/.env.local" + "Using secrets defined in other/.env + Using secrets defined in other/.env.local" `); expect(extractBindings(out)).toMatchInlineSnapshot(` "env.__DOT_ENV_LOCAL_DEV_VAR_1 ("(hidden)") Environment Variable local @@ -2216,7 +2373,7 @@ describe.sequential("wrangler dev", () => { " ⛅️ wrangler x.x.x ────────────────── - Using vars defined in .dev.vars + Using secrets defined in .dev.vars Your Worker has access to the following bindings: Binding Resource Mode env.variable (123) Environment Variable local diff --git a/packages/wrangler/src/config/dot-env.ts b/packages/wrangler/src/config/dot-env.ts index eca8fff22783..d0acf0262eaf 100644 --- a/packages/wrangler/src/config/dot-env.ts +++ b/packages/wrangler/src/config/dot-env.ts @@ -61,7 +61,7 @@ export function loadDotEnv( } } else if (parsed && !silent) { const relativePath = path.relative(process.cwd(), envPath); - logger.log(`Using vars defined in ${relativePath}`); + logger.log(`Using secrets defined in ${relativePath}`); } } @@ -71,7 +71,7 @@ export function loadDotEnv( if (includeProcessEnv) { Object.assign(expandedEnv, process.env); if (!silent) { - logger.log("Using vars defined in process.env"); + logger.log("Using secrets defined in process.env"); } } const { error } = dotenvExpand.expand({ diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index e92400746469..21b1fac056a4 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -505,12 +505,15 @@ export function getBindings( // Override vars with .dev.vars (dev-specific) // getVarsForDev returns typed bindings: config vars are plain_text/json, - // while .dev.vars/.env vars are secret_text + // while .dev.vars/.env vars are secret_text. + // When secrets is defined, only declared secret keys are loaded from files. const vars = getVarsForDev( configParam.userConfigPath, envFiles, configParam.vars, - env + env, + false, + configParam.secrets ); for (const [name, binding] of Object.entries(vars)) { // Only override plain_text/json/secret_text vars, not other binding types like kv_namespace diff --git a/packages/wrangler/src/dev/dev-vars.ts b/packages/wrangler/src/dev/dev-vars.ts index b2d4eaeeb64f..2e4cbfe7dbbc 100644 --- a/packages/wrangler/src/dev/dev-vars.ts +++ b/packages/wrangler/src/dev/dev-vars.ts @@ -40,12 +40,17 @@ export type VarBinding = Extract< * Any values in these files (all formatted like `.env` files) will add to or override `vars` * bindings provided in the Wrangler configuration file. * + * When `secrets` is defined in the config, only the declared secret keys are loaded from + * `.dev.vars`/`.env`/`process.env`. All other keys in those files are excluded. A warning + * is emitted for any required secrets that are missing. + * * @param configPath - The path to the Wrangler configuration file, if defined. * @param envFiles - An array of paths to .env files to load; if `undefined` the default .env files will be used (see `getDefaultEnvFiles()`). * The `envFiles` paths are resolved against the directory of the Wrangler configuration file, if there is one, otherwise against the current working directory. * @param vars - The existing `vars` bindings from the Wrangler configuration. * @param env - The specific environment name (e.g., "staging") or `undefined` if no specific environment is set. * @param silent - If true, will not log any messages about the loaded .dev.vars files or .env files. + * @param secrets - If defined, only the declared secret keys are loaded from `.dev.vars` or `.env`/`process.env`. * @returns The merged `vars` as typed bindings. Config vars are `plain_text`/`json`, while `.dev.vars`/`.env` vars are `secret_text`. */ export function getVarsForDev( @@ -53,7 +58,8 @@ export function getVarsForDev( envFiles: string[] | undefined, vars: Config["vars"], env: string | undefined, - silent = false + silent = false, + secrets?: Config["secrets"] ): Record { // Start with config vars (plain_text or json, not secret) const result: Record = {}; @@ -63,6 +69,9 @@ export function getVarsForDev( const configDir = path.resolve(configPath ? path.dirname(configPath) : "."); + // Load secrets from .dev.vars, .env files, or process.env + let loadedSecrets: Record | undefined; + // If envFiles are not explicitly provided, try to load from .dev.vars first if (!envFiles?.length) { const devVarsPath = path.resolve(configDir, ".dev.vars"); @@ -70,34 +79,53 @@ export function getVarsForDev( if (loaded !== undefined) { const devVarsRelativePath = path.relative(process.cwd(), loaded.path); if (!silent) { - logger.log(`Using vars defined in ${devVarsRelativePath}`); - } - // Merge .dev.vars as secret_text - for (const [key, value] of Object.entries(loaded.parsed)) { - result[key] = { type: "secret_text", value }; + logger.log(`Using secrets defined in ${devVarsRelativePath}`); } - return result; + loadedSecrets = loaded.parsed; } } - // If .dev.vars wasn't loaded (either because envFiles was explicit or .dev.vars doesn't exist), - // try loading from .env files - if (getCloudflareLoadDevVarsFromDotEnv()) { + // If .dev.vars wasn't loaded, try loading from .env files + if (loadedSecrets === undefined && getCloudflareLoadDevVarsFromDotEnv()) { const resolvedEnvFilePaths = (envFiles ?? getDefaultEnvFiles(env)).map( (p) => path.resolve(configDir, p) ); - const dotEnvVars = loadDotEnv(resolvedEnvFilePaths, { - includeProcessEnv: getCloudflareIncludeProcessEnvFromEnv(), + loadedSecrets = loadDotEnv(resolvedEnvFilePaths, { + // When secrets is defined, always include `process.env`. + // Otherwise, respect the CLOUDFLARE_INCLUDE_PROCESS_ENV env var. + includeProcessEnv: !!secrets || getCloudflareIncludeProcessEnvFromEnv(), silent, }); - // Merge .env vars as secret_text - for (const [key, value] of Object.entries(dotEnvVars)) { - result[key] = { type: "secret_text", value: String(value) }; + } + + // Merge loaded secrets into result + if (secrets) { + // Explicit secrets: only declared secret keys + const requiredSecrets = secrets.required ?? []; + for (const key of requiredSecrets) { + if (loadedSecrets !== undefined && key in loadedSecrets) { + result[key] = { type: "secret_text", value: loadedSecrets[key] }; + } + } + // Warn about missing required secrets + if (!silent) { + const missing = requiredSecrets.filter( + (key) => loadedSecrets === undefined || !(key in loadedSecrets) + ); + if (missing.length > 0) { + logger.warn( + `Missing required secrets: ${missing.join(", ")}. ` + + `Add them to .dev.vars, .env, or set as environment variables.` + ); + } + } + } else if (loadedSecrets !== undefined) { + // Implicit secrets: merge all file vars as secret_text + for (const [key, value] of Object.entries(loadedSecrets)) { + result[key] = { type: "secret_text", value }; } - return result; } - // Just return the vars from the Wrangler configuration. return result; } From 35b2c56cdef6f4e7d33a885959f4ce8fc01201d0 Mon Sep 17 00:00:00 2001 From: Gabi Date: Tue, 3 Mar 2026 04:32:06 -0600 Subject: [PATCH 2/3] containers: Add container and test Containers interceptOutboundHttp (#12649) --- .changeset/empty-radios-happen.md | 12 ++++ packages/containers-shared/src/images.ts | 25 +++++++ packages/miniflare/src/plugins/core/index.ts | 51 +++++++++++++-- .../src/runtime/config/generated/workerd.ts | 15 ++++- .../miniflare/src/runtime/config/workerd.ts | 1 + .../vite-plugin-cloudflare/src/plugins/dev.ts | 3 + .../src/plugins/preview.ts | 3 + .../vitest-pool-workers/test/global-setup.ts | 2 +- .../workers-utils/src/config/environment.ts | 2 + packages/wrangler/e2e/containers.dev.test.ts | 65 ++++++++++++++++++- .../startDevWorker/LocalRuntimeController.ts | 2 + .../MultiworkerRuntimeController.ts | 2 + turbo.json | 1 + 13 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 .changeset/empty-radios-happen.md diff --git a/.changeset/empty-radios-happen.md b/.changeset/empty-radios-happen.md new file mode 100644 index 000000000000..1b890faab504 --- /dev/null +++ b/.changeset/empty-radios-happen.md @@ -0,0 +1,12 @@ +--- +"@cloudflare/vite-plugin": minor +"@cloudflare/containers-shared": minor +"@cloudflare/workers-utils": minor +"miniflare": minor +"wrangler": minor +--- + +Add experimental support for containers to workers communication with interceptOutboundHttp + +This feature is experimental and requires adding the "experimental" +compatibility flag to your Wrangler configuration. diff --git a/packages/containers-shared/src/images.ts b/packages/containers-shared/src/images.ts index beb924d5f4f0..61f828d85928 100644 --- a/packages/containers-shared/src/images.ts +++ b/packages/containers-shared/src/images.ts @@ -17,6 +17,23 @@ import type { WranglerLogger, } from "./types"; +const DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE = + "cloudflare/proxy-everything:4dc6c7f@sha256:9621ef445ef120409e5d95bbd845ab2fa0f613636b59a01d998f5704f4096ae2"; + +export function getEgressInterceptorImage(): string { + return ( + process.env.MINIFLARE_CONTAINER_EGRESS_IMAGE ?? + DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE + ); +} + +export async function pullEgressInterceptorImage( + dockerPath: string +): Promise { + const image = getEgressInterceptorImage(); + await runDockerCmd(dockerPath, ["pull", image, "--platform", "linux/amd64"]); +} + export async function pullImage( dockerPath: string, options: Exclude, @@ -97,6 +114,7 @@ export async function prepareContainerImagesForDev(args: { }) => void; logger: WranglerLogger | ViteLogger; isVite: boolean; + compatibilityFlags?: string[]; }): Promise { const { dockerPath, @@ -152,6 +170,13 @@ export async function prepareContainerImagesForDev(args: { await checkExposedPorts(dockerPath, options); } } + + // Pull the egress interceptor image if experimental flag is enabled. + // This image is used to intercept outbound HTTP from containers and + // route it back to workerd (e.g. for interceptOutboundHttp). + if (!aborted && args.compatibilityFlags?.includes("experimental")) { + await pullEgressInterceptorImage(dockerPath); + } } /** diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 92ed45905e1c..bab0e67c0d3a 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -192,7 +192,10 @@ const CoreOptionsSchemaInput = z.intersection( containerEngine: z .union([ z.object({ - localDocker: z.object({ socketPath: z.string() }), + localDocker: z.object({ + socketPath: z.string(), + containerEgressInterceptorImage: z.string().optional(), + }), }), z.string(), ]) @@ -905,7 +908,10 @@ export const CORE_PLUGIN: Plugin< ); } ), - containerEngine: getContainerEngine(options.containerEngine), + containerEngine: getContainerEngine( + options.containerEngine, + options.compatibilityFlags + ), }, }); } @@ -1210,13 +1216,28 @@ function getWorkerScript( } } +const DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE = + "cloudflare/proxy-everything:4dc6c7f@sha256:9621ef445ef120409e5d95bbd845ab2fa0f613636b59a01d998f5704f4096ae2"; + +/** + * Returns the default containerEgressInterceptorImage. It's used for + * container network interception for local dev. + */ +function getContainerEgressInterceptorImage(): string { + return ( + process.env.MINIFLARE_CONTAINER_EGRESS_IMAGE ?? + DEFAULT_CONTAINER_EGRESS_INTERCEPTOR_IMAGE + ); +} + /** * Returns the Container engine configuration * @param engineOrSocketPath Either a full engine config or a unix socket * @returns The container engine, defaulting to the default docker socket located on linux/macOS at `unix:///var/run/docker.sock` */ function getContainerEngine( - engineOrSocketPath: Worker_ContainerEngine | string | undefined + engineOrSocketPath: Worker_ContainerEngine | string | undefined, + compatibilityFlags?: string[] ): Worker_ContainerEngine { if (!engineOrSocketPath) { // TODO: workerd does not support win named pipes @@ -1226,11 +1247,31 @@ function getContainerEngine( : "unix:///var/run/docker.sock"; } + // TODO: Once the feature becomes GA, we should remove the experimental requirement. + // Egress interceptor is to support direct connectivity between the Container and Workers, + // it spawns a container in the same network namespace as the local dev container and + // intercepts traffic to redirect to Workerd. + const egressImage = compatibilityFlags?.includes("experimental") + ? getContainerEgressInterceptorImage() + : undefined; + if (typeof engineOrSocketPath === "string") { - return { localDocker: { socketPath: engineOrSocketPath } }; + return { + localDocker: { + socketPath: engineOrSocketPath, + containerEgressInterceptorImage: egressImage, + }, + }; } - return engineOrSocketPath; + return { + localDocker: { + ...engineOrSocketPath.localDocker, + containerEgressInterceptorImage: + engineOrSocketPath.localDocker.containerEgressInterceptorImage ?? + egressImage, + }, + }; } export * from "./errors"; diff --git a/packages/miniflare/src/runtime/config/generated/workerd.ts b/packages/miniflare/src/runtime/config/generated/workerd.ts index 36df22d4c0b0..9ac5a1093bcd 100644 --- a/packages/miniflare/src/runtime/config/generated/workerd.ts +++ b/packages/miniflare/src/runtime/config/generated/workerd.ts @@ -2734,7 +2734,7 @@ export class Worker_DockerConfiguration extends $.Struct { static readonly _capnp = { displayName: "DockerConfiguration", id: "e62f96c20d9fb872", - size: new $.ObjectSize(0, 1), + size: new $.ObjectSize(0, 2), }; /** * Path to the Docker socket. @@ -2746,6 +2746,19 @@ export class Worker_DockerConfiguration extends $.Struct { set socketPath(value: string) { $.utils.setText(0, value, this); } + /** + * Docker image name for the container egress interceptor sidecar. + * This sidecar intercepts outbound traffic from containers and routes it + * through workerd for egress mappings (setEgressHttp bindings). + * You can find this image in repositories like DockerHub: https://hub.docker.com/r/cloudflare/proxy-everything + * + */ + get containerEgressInterceptorImage(): string { + return $.utils.getText(1, this); + } + set containerEgressInterceptorImage(value: string) { + $.utils.setText(1, value, this); + } toString(): string { return "Worker_DockerConfiguration_" + super.toString(); } diff --git a/packages/miniflare/src/runtime/config/workerd.ts b/packages/miniflare/src/runtime/config/workerd.ts index e66121a64d58..1c6469104ba1 100644 --- a/packages/miniflare/src/runtime/config/workerd.ts +++ b/packages/miniflare/src/runtime/config/workerd.ts @@ -52,6 +52,7 @@ export interface ServiceDesignator { export type Worker_DockerConfiguration = { socketPath: string; + containerEgressInterceptorImage?: string; }; export type Worker_ContainerEngine = { diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index 9554d10f0db7..62982f334cbb 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -208,6 +208,9 @@ export const devPlugin = createPlugin("dev", (ctx) => { onContainerImagePreparationEnd: () => {}, logger: viteDevServer.config.logger, isVite: true, + compatibilityFlags: ctx.allWorkerConfigs.flatMap( + (c) => c.compatibility_flags + ), }); containerImageTags = new Set(containerTagToOptionsMap.keys()); diff --git a/packages/vite-plugin-cloudflare/src/plugins/preview.ts b/packages/vite-plugin-cloudflare/src/plugins/preview.ts index de4ddbb40a36..ece7b7edec32 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/preview.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/preview.ts @@ -49,6 +49,9 @@ export const previewPlugin = createPlugin("preview", (ctx) => { onContainerImagePreparationEnd: () => {}, logger: vitePreviewServer.config.logger, isVite: true, + compatibilityFlags: ctx.allWorkerConfigs.flatMap( + (c) => c.compatibility_flags + ), }); const containerImageTags = new Set(containerTagToOptionsMap.keys()); diff --git a/packages/vitest-pool-workers/test/global-setup.ts b/packages/vitest-pool-workers/test/global-setup.ts index ffc2ec731f1b..875c1ca84d7c 100644 --- a/packages/vitest-pool-workers/test/global-setup.ts +++ b/packages/vitest-pool-workers/test/global-setup.ts @@ -28,7 +28,7 @@ export default async function ({ provide }: GlobalSetupContext) { await stop(); console.log("Cleaning up temporary directory..."); - removeDir(projectPath, { fireAndForget: true }); + void removeDir(projectPath, { fireAndForget: true }); }; } diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index c5d3ba4724cd..e2216a72d9eb 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1464,6 +1464,8 @@ export interface CacheOptions { export type DockerConfiguration = { /** Socket used by miniflare to communicate with Docker */ socketPath: string; + /** Docker image name for the container egress interceptor sidecar */ + containerEgressInterceptorImage?: string; }; export type ContainerEngine = diff --git a/packages/wrangler/e2e/containers.dev.test.ts b/packages/wrangler/e2e/containers.dev.test.ts index e9abe1b3e1bc..4fcf1e736eb7 100644 --- a/packages/wrangler/e2e/containers.dev.test.ts +++ b/packages/wrangler/e2e/containers.dev.test.ts @@ -47,6 +47,7 @@ for (const source of imageSource) { name: `${workerName}`, main: "src/index.ts", compatibility_date: "2025-04-03", + compatibility_flags: ["experimental", "enable_ctx_exports"], containers: [ { image: "./Dockerfile", @@ -72,7 +73,13 @@ for (const source of imageSource) { await helper.seed({ "wrangler.json": JSON.stringify(wranglerConfig), "src/index.ts": dedent` - import { DurableObject } from "cloudflare:workers"; + import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; + + export class TestService extends WorkerEntrypoint { + async fetch(req: Request) { + return new Response("hello from worker"); + } + } export class E2EContainer extends DurableObject { container: globalThis.Container; @@ -101,6 +108,22 @@ for (const source of imageSource) { .getTcpPort(8080) .fetch("http://foo/bar/baz"); return new Response(await res.text()); + + case "/setup-intercept": + await this.container.interceptOutboundHttp( + "11.0.0.1:80", + this.ctx.exports.TestService({ props: {} }) + ); + return new Response("Intercept setup done"); + + case "/fetch-intercept": + const interceptRes = await this.container + .getTcpPort(8080) + .fetch("http://foo/intercept", { + headers: { "x-host": "11.0.0.1:80" }, + }); + return new Response(await interceptRes.text()); + default: return new Response("Hi from Container DO"); } @@ -126,6 +149,23 @@ for (const source of imageSource) { const { createServer } = require("http"); const server = createServer(function (req, res) { + if (req.url === "/intercept") { + const targetHost = req.headers["x-host"] || "11.0.0.1"; + fetch("http://" + targetHost) + .then(function (result) { return result.text(); }) + .then(function (body) { + res.writeHead(200); + res.write(body); + res.end(); + }) + .catch(function (err) { + res.writeHead(500); + res.write(targetHost + " " + err.message); + res.end(); + }); + return; + } + res.writeHead(200, { "Content-Type": "text/plain" }); res.write("Hello World! Have an env var! " + process.env.MESSAGE); res.end(); @@ -241,6 +281,29 @@ for (const source of imageSource) { { timeout: 5_000 } ); + // Set up egress HTTP interception so the container can call back to the worker + response = await fetch(`${ready.url}/setup-intercept`, { + signal: AbortSignal.timeout(5_000), + headers: { "MF-Disable-Pretty-Error": "true" }, + }); + text = await response.text(); + expect(response.status).toBe(200); + expect(text).toBe("Intercept setup done"); + + // Fetch through the container's /intercept route which curls back to the worker + await vi.waitFor( + async () => { + response = await fetch(`${ready.url}/fetch-intercept`, { + signal: AbortSignal.timeout(5_000), + headers: { "MF-Disable-Pretty-Error": "true" }, + }); + text = await response.text(); + expect(response.status).toBe(200); + expect(text).toBe("hello from worker"); + }, + { timeout: 10_000 } + ); + // Check that a container is running using `docker ps` const ids = getContainerIds("e2econtainer"); expect(ids.length).toBe(1); diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 724d578c6385..23805b5ea615 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -270,10 +270,12 @@ export class LocalRuntimeController extends RuntimeController { }, logger: logger, isVite: false, + compatibilityFlags: data.config.compatibilityFlags, }); if (this.containerBeingBuilt) { this.containerBeingBuilt.abortRequested = false; } + this.#currentContainerBuildId = data.config.dev.containerBuildId; // Miniflare will have logged 'Ready on...' before the containers are built, but that is actually the proxy server :/ // The actual user worker's miniflare instance is blocked until the containers are built diff --git a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts index 3610554743a5..722e957c4f9d 100644 --- a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts @@ -168,10 +168,12 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { }, logger: logger, isVite: false, + compatibilityFlags: data.config.compatibilityFlags, }); if (this.containerBeingBuilt) { this.containerBeingBuilt.abortRequested = false; } + this.#currentContainerBuildId = data.config.dev.containerBuildId; // Miniflare will have logged 'Ready on...' before the containers are built, but that is actually the proxy server :/ // The actual user worker's miniflare instance is blocked until the containers are built diff --git a/turbo.json b/turbo.json index 287ebf07ae29..5a82773fed83 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,7 @@ "VSCODE_INSPECTOR_OPTIONS", "WRANGLER_API_ENVIRONMENT", "WRANGLER_CACHE_DIR", + "MINIFLARE_CONTAINER_EGRESS_IMAGE", "WRANGLER_DOCKER_HOST", "WRANGLER_LOG_PATH", "WRANGLER_LOG" From a3f9d1a4bfe15f65057b23b2284e55ace2b8bb3e Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Tue, 3 Mar 2026 12:59:48 +0000 Subject: [PATCH 3/3] Make `workers-utils` package private (#12658) --- packages/workers-utils/package.json | 1 + tools/deployments/__tests__/validate-changesets.test.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json index 54d584e598b3..96da86f21c51 100644 --- a/packages/workers-utils/package.json +++ b/packages/workers-utils/package.json @@ -1,6 +1,7 @@ { "name": "@cloudflare/workers-utils", "version": "0.12.0", + "private": true, "description": "Utility package for common Worker operations", "homepage": "https://github.com/cloudflare/workers-sdk#readme", "bugs": { diff --git a/tools/deployments/__tests__/validate-changesets.test.ts b/tools/deployments/__tests__/validate-changesets.test.ts index deb0a7ee4ae7..b8f17b61c18b 100644 --- a/tools/deployments/__tests__/validate-changesets.test.ts +++ b/tools/deployments/__tests__/validate-changesets.test.ts @@ -39,7 +39,6 @@ describe("findPackageNames()", () => { "@cloudflare/workers-editor-shared", "@cloudflare/workers-playground", "@cloudflare/workers-shared", - "@cloudflare/workers-utils", "@cloudflare/workflows-shared", "create-cloudflare", "miniflare",