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
12 changes: 12 additions & 0 deletions .changeset/empty-radios-happen.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/quiet-queens-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"wrangler": minor
---

Add dev support for experimental `secrets` property.
25 changes: 25 additions & 0 deletions packages/containers-shared/src/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const image = getEgressInterceptorImage();
await runDockerCmd(dockerPath, ["pull", image, "--platform", "linux/amd64"]);
}

export async function pullImage(
dockerPath: string,
options: Exclude<ContainerDevOptions, DockerfileConfig>,
Expand Down Expand Up @@ -97,6 +114,7 @@ export async function prepareContainerImagesForDev(args: {
}) => void;
logger: WranglerLogger | ViteLogger;
isVite: boolean;
compatibilityFlags?: string[];
}): Promise<void> {
const {
dockerPath,
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
51 changes: 46 additions & 5 deletions packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
])
Expand Down Expand Up @@ -905,7 +908,10 @@ export const CORE_PLUGIN: Plugin<
);
}
),
containerEngine: getContainerEngine(options.containerEngine),
containerEngine: getContainerEngine(
options.containerEngine,
options.compatibilityFlags
),
},
});
}
Expand Down Expand Up @@ -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
Expand All @@ -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";
Expand Down
15 changes: 14 additions & 1 deletion packages/miniflare/src/runtime/config/generated/workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
}
Expand Down
1 change: 1 addition & 0 deletions packages/miniflare/src/runtime/config/workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface ServiceDesignator {

export type Worker_DockerConfiguration = {
socketPath: string;
containerEgressInterceptorImage?: string;
};

export type Worker_ContainerEngine = {
Expand Down
12 changes: 8 additions & 4 deletions packages/vite-plugin-cloudflare/src/dev-vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions packages/vite-plugin-cloudflare/src/plugins/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions packages/vite-plugin-cloudflare/src/plugins/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest-pool-workers/test/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/workers-utils/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions packages/workers-utils/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
65 changes: 64 additions & 1 deletion packages/wrangler/e2e/containers.dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<Env> {
container: globalThis.Container;
Expand Down Expand Up @@ -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");
}
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/wrangler/src/__tests__/config/loadDotEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
`);
});

Expand Down
Loading
Loading