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
6 changes: 6 additions & 0 deletions .changeset/clever-snails-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"miniflare": minor
"wrangler": minor
---

VPC service binding support
4 changes: 4 additions & 0 deletions packages/miniflare/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { R2_PLUGIN, R2_PLUGIN_NAME } from "./r2";
import { RATELIMIT_PLUGIN, RATELIMIT_PLUGIN_NAME } from "./ratelimit";
import { SECRET_STORE_PLUGIN, SECRET_STORE_PLUGIN_NAME } from "./secret-store";
import { VECTORIZE_PLUGIN, VECTORIZE_PLUGIN_NAME } from "./vectorize";
import { VPC_SERVICES_PLUGIN, VPC_SERVICES_PLUGIN_NAME } from "./vpc-services";
import {
WORKER_LOADER_PLUGIN,
WORKER_LOADER_PLUGIN_NAME,
Expand Down Expand Up @@ -58,6 +59,7 @@ export const PLUGINS = {
[DISPATCH_NAMESPACE_PLUGIN_NAME]: DISPATCH_NAMESPACE_PLUGIN,
[IMAGES_PLUGIN_NAME]: IMAGES_PLUGIN,
[VECTORIZE_PLUGIN_NAME]: VECTORIZE_PLUGIN,
[VPC_SERVICES_PLUGIN_NAME]: VPC_SERVICES_PLUGIN,
[MTLS_PLUGIN_NAME]: MTLS_PLUGIN,
[HELLO_WORLD_PLUGIN_NAME]: HELLO_WORLD_PLUGIN,
[WORKER_LOADER_PLUGIN_NAME]: WORKER_LOADER_PLUGIN,
Expand Down Expand Up @@ -119,6 +121,7 @@ export type WorkerOptions = z.input<typeof CORE_PLUGIN.options> &
z.input<typeof DISPATCH_NAMESPACE_PLUGIN.options> &
z.input<typeof IMAGES_PLUGIN.options> &
z.input<typeof VECTORIZE_PLUGIN.options> &
z.input<typeof VPC_SERVICES_PLUGIN.options> &
z.input<typeof MTLS_PLUGIN.options> &
z.input<typeof HELLO_WORLD_PLUGIN.options> &
z.input<typeof WORKER_LOADER_PLUGIN.options>;
Expand Down Expand Up @@ -190,6 +193,7 @@ export * from "./browser-rendering";
export * from "./dispatch-namespace";
export * from "./images";
export * from "./vectorize";
export * from "./vpc-services";
export * from "./mtls";
export * from "./hello-world";
export * from "./worker-loader";
84 changes: 84 additions & 0 deletions packages/miniflare/src/plugins/vpc-services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert";
import { z } from "zod";
import {
getUserBindingServiceName,
Plugin,
ProxyNodeBinding,
remoteProxyClientWorker,
RemoteProxyConnectionString,
} from "../shared";

const VpcServicesSchema = z.object({
service_id: z.string(),
remoteProxyConnectionString: z.custom<RemoteProxyConnectionString>(),
});

export const VpcServicesOptionsSchema = z.object({
vpcServices: z.record(VpcServicesSchema).optional(),
});

export const VPC_SERVICES_PLUGIN_NAME = "vpc-services";

export const VPC_SERVICES_PLUGIN: Plugin<typeof VpcServicesOptionsSchema> = {
options: VpcServicesOptionsSchema,
async getBindings(options) {
if (!options.vpcServices) {
return [];
}

return Object.entries(options.vpcServices).map(
([name, { service_id, remoteProxyConnectionString }]) => {
assert(
remoteProxyConnectionString,
"VPC Services only supports running remotely"
);

return {
name,

service: {
name: getUserBindingServiceName(
VPC_SERVICES_PLUGIN_NAME,
service_id,
remoteProxyConnectionString
),
},
};
}
);
},
getNodeBindings(options: z.infer<typeof VpcServicesOptionsSchema>) {
if (!options.vpcServices) {
return {};
}
return Object.fromEntries(
Object.keys(options.vpcServices).map((name) => [
name,
new ProxyNodeBinding(),
])
);
},
async getServices({ options }) {
if (!options.vpcServices) {
return [];
}

return Object.entries(options.vpcServices).map(
([name, { service_id, remoteProxyConnectionString }]) => {
assert(
remoteProxyConnectionString,
"VPC Services only supports running remotely"
);

return {
name: getUserBindingServiceName(
VPC_SERVICES_PLUGIN_NAME,
service_id,
remoteProxyConnectionString
),
worker: remoteProxyClientWorker(remoteProxyConnectionString, name),
};
}
);
},
};
41 changes: 41 additions & 0 deletions packages/wrangler/e2e/helpers/e2e-wrangler-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,45 @@ export class WranglerE2ETestHelper {
return { deployedUrl, stdout, cleanup };
}
}

async tunnel(): Promise<string> {
const Cloudflare = (await import("cloudflare")).default;

const name = generateResourceName("tunnel");
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
if (!accountId) {
throw new Error("CLOUDFLARE_ACCOUNT_ID environment variable is required");
}

// Create Cloudflare client directly
const client = new Cloudflare({
apiToken: process.env.CLOUDFLARE_API_TOKEN,
});

// Create tunnel via Cloudflare SDK
const tunnel = await client.zeroTrust.tunnels.cloudflared.create({
account_id: accountId,
name,
config_src: "cloudflare",
});

if (!tunnel.id) {
throw new Error("Failed to create tunnel: tunnel ID is undefined");
}

const tunnelId = tunnel.id;

onTestFinished(async () => {
try {
await client.zeroTrust.tunnels.cloudflared.delete(tunnelId, {
account_id: accountId,
});
} catch (error) {
// Ignore deletion errors in cleanup
console.warn(`Failed to delete tunnel ${tunnelId}:`, error);
}
});

return tunnelId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,58 @@ const testCases: TestCase<string>[] = [
),
],
},
/* {
// Enable post announcement
name: "VPC Service",
scriptPath: "vpc-service.js",
setup: async (helper) => {
const serviceName = generateResourceName();

// Create a real Cloudflare tunnel for testing
const tunnelId = await helper.tunnel();

const output = await helper.run(
`wrangler vpc service create ${serviceName} --type http --ipv4 10.0.0.1 --http-port 8080 --tunnel-id ${tunnelId}`
);

// Extract service_id from output
const match = output.stdout.match(
/Created VPC service:\s+(?<serviceId>[\w-]+)/
);
const serviceId = match?.groups?.serviceId;
assert(
serviceId,
"Failed to extract service ID from VPC service creation output"
);

onTestFinished(async () => {
await helper.run(`wrangler vpc service delete ${serviceId}`);
});

return serviceId;
},
remoteProxySessionConfig: (serviceId) => [
{
VPC_SERVICE: {
type: "vpc_service",
service_id: serviceId,
},
},
],
miniflareConfig: (connection, serviceId) => ({
vpcServices: {
VPC_SERVICE: {
service_id: serviceId,
remoteProxyConnectionString: connection,
},
},
}),
matches: [
// Since we're using a real tunnel but no actual network connectivity, Iris will report back an error
// but this is considered an effective test for wrangler and vpc service bindings
expect.stringMatching(/CONNECT failed: 503 Service Unavailable/),
],
}, */
];

const mtlsTest: TestCase<{ certificateId: string; workerName: string }> = {
Expand Down
6 changes: 6 additions & 0 deletions packages/wrangler/e2e/remote-binding/workers/vpc-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
async fetch(request, env, ctx) {
const response = await env.VPC_SERVICE.fetch("http://10.0.0.1:8080/");
return new Response(await response.text());
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ describe("convertConfigBindingsToStartWorkerBindings", () => {
class_name: "MyWorkflow",
},
],
vpc_services: [
{
binding: "MY_VPC_SERVICE",
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
},
],
});
expect(result).toEqual({
AI: {
Expand Down Expand Up @@ -125,6 +131,10 @@ describe("convertConfigBindingsToStartWorkerBindings", () => {
name: "workflow",
type: "workflow",
},
MY_VPC_SERVICE: {
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
type: "vpc_service",
},
});
});

Expand Down Expand Up @@ -165,6 +175,7 @@ describe("convertConfigBindingsToStartWorkerBindings", () => {
],
mtls_certificates: [],
workflows: [],
vpc_services: [],
});

assert(result);
Expand Down
64 changes: 64 additions & 0 deletions packages/wrangler/src/__tests__/config/configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ describe("normalizeAndValidateConfig()", () => {
secrets_store_secrets: [],
unsafe_hello_world: [],
ratelimits: [],
vpc_services: [],
services: [],
analytics_engine_datasets: [],
route: undefined,
Expand Down Expand Up @@ -4281,6 +4282,69 @@ describe("normalizeAndValidateConfig()", () => {
});
});

describe("[vpc_services]", () => {
it("should accept valid bindings", () => {
const { config, diagnostics } = normalizeAndValidateConfig(
{
vpc_services: [
{
binding: "MYAPI",
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
},
{
binding: "DATABASE",
service_id: "0299295b-b3ac-7760-8246-bca40877b3e0",
},
],
} as RawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(config.vpc_services).toEqual([
{
binding: "MYAPI",
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
},
{
binding: "DATABASE",
service_id: "0299295b-b3ac-7760-8246-bca40877b3e0",
},
]);
expect(diagnostics.hasErrors()).toBe(false);
});

it("should error if vpc_services bindings are not valid", () => {
const { diagnostics } = normalizeAndValidateConfig(
{
vpc_services: [
{},
{
binding: "VALID",
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
},
{ binding: null, service_id: 123, invalid: true },
{ binding: "MISSING_SERVICE_ID" },
],
} as unknown as RawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(diagnostics.hasErrors()).toBe(true);
expect(diagnostics.renderErrors()).toMatchInlineSnapshot(`
"Processing wrangler configuration:
- \\"vpc_services[0]\\" bindings should have a string \\"binding\\" field but got {}.
- \\"vpc_services[0]\\" bindings must have a \\"service_id\\" field but got {}.
- \\"vpc_services[2]\\" bindings should have a string \\"binding\\" field but got {\\"binding\\":null,\\"service_id\\":123,\\"invalid\\":true}.
- \\"vpc_services[2]\\" bindings must have a \\"service_id\\" field but got {\\"binding\\":null,\\"service_id\\":123,\\"invalid\\":true}.
- \\"vpc_services[3]\\" bindings must have a \\"service_id\\" field but got {\\"binding\\":\\"MISSING_SERVICE_ID\\"}."
`);
});
});

describe("[unsafe.bindings]", () => {
it("should error if unsafe is an array", () => {
const { diagnostics } = normalizeAndValidateConfig(
Expand Down
Loading
Loading