From 785cda40fb483ec93c9a4f4781ac74a8d6b97994 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:53:54 -0500 Subject: [PATCH 1/4] feat(cloudflare): vpc service --- alchemy/src/cloudflare/api-error.ts | 19 ++ alchemy/src/cloudflare/bindings.ts | 19 +- alchemy/src/cloudflare/bound.ts | 9 +- alchemy/src/cloudflare/index.ts | 1 + .../miniflare/build-worker-options.ts | 48 ++-- alchemy/src/cloudflare/vpc-service.ts | 269 ++++++++++++++++++ alchemy/src/cloudflare/worker-metadata.ts | 7 + alchemy/src/cloudflare/wrangler.json.ts | 7 + .../cloudflare-vpc-service/alchemy.run.ts | 34 +++ examples/cloudflare-vpc-service/package.json | 14 + examples/cloudflare-vpc-service/src/worker.ts | 9 + examples/cloudflare-vpc-service/tsconfig.json | 13 + 12 files changed, 425 insertions(+), 24 deletions(-) create mode 100644 alchemy/src/cloudflare/vpc-service.ts create mode 100644 examples/cloudflare-vpc-service/alchemy.run.ts create mode 100644 examples/cloudflare-vpc-service/package.json create mode 100644 examples/cloudflare-vpc-service/src/worker.ts create mode 100644 examples/cloudflare-vpc-service/tsconfig.json diff --git a/alchemy/src/cloudflare/api-error.ts b/alchemy/src/cloudflare/api-error.ts index 5239d1e42..85eecdbdd 100644 --- a/alchemy/src/cloudflare/api-error.ts +++ b/alchemy/src/cloudflare/api-error.ts @@ -1,3 +1,5 @@ +import type { CloudflareApiErrorPayload } from "./api-response"; + /** * Custom error class for Cloudflare API errors * Includes HTTP status information from the Response @@ -100,3 +102,20 @@ export async function handleApiError( // End of Selection throw new CloudflareApiError(errorMessage, response, errors); } + +/** + * Helper function to check if an error is a Cloudflare API error. + * Optional match criteria can be provided to check for a specific HTTP status code or error code. + */ +export function isCloudflareApiError( + error: unknown, + match: { status?: number; code?: number } = {}, +): error is CloudflareApiError & { errorData: CloudflareApiErrorPayload[] } { + return ( + error instanceof CloudflareApiError && + (match.status === undefined || error.status === match.status) && + (match.code === undefined || + (Array.isArray(error.errorData) && + error.errorData.some((e) => "code" in e && e.code === match.code))) + ); +} diff --git a/alchemy/src/cloudflare/bindings.ts b/alchemy/src/cloudflare/bindings.ts index e8f1b119b..62a51e048 100644 --- a/alchemy/src/cloudflare/bindings.ts +++ b/alchemy/src/cloudflare/bindings.ts @@ -26,10 +26,11 @@ import type { SecretRef as CloudflareSecretRef } from "./secret-ref.ts"; import type { Secret as CloudflareSecret } from "./secret.ts"; import type { VectorizeIndex } from "./vectorize-index.ts"; import type { VersionMetadata } from "./version-metadata.ts"; +import type { VpcService } from "./vpc-service.ts"; +import type { WorkerLoader } from "./worker-loader.ts"; import type { WorkerRef } from "./worker-ref.ts"; import type { WorkerStub } from "./worker-stub.ts"; import type { Worker } from "./worker.ts"; -import type { WorkerLoader } from "./worker-loader.ts"; import type { Workflow } from "./workflow.ts"; export type Bindings = { @@ -80,7 +81,8 @@ export type Binding = | BrowserRendering | VersionMetadata | Self - | Json; + | Json + | VpcService; export type Self< RPC extends Rpc.WorkerEntrypointBranded = Rpc.WorkerEntrypointBranded, @@ -141,7 +143,8 @@ export type WorkerBindingSpec = | WorkerBindingVersionMetadata | WorkerBindingWasmModule | WorkerBindingWorkerLoader - | WorkerBindingWorkflow; + | WorkerBindingWorkflow + | WorkerBindingVpcService; /** * AI binding type @@ -497,6 +500,16 @@ export interface WorkerBindingWorkflow { script_name?: string; } +export interface WorkerBindingVpcService { + /** The name of the binding */ + name: string; + /** Type identifier for VPC Service binding */ + type: "vpc_service"; + /** VPC Service name */ + service_name: string; + service_id: string; +} + /** * Images binding type */ diff --git a/alchemy/src/cloudflare/bound.ts b/alchemy/src/cloudflare/bound.ts index 3bf270e92..c0336678e 100644 --- a/alchemy/src/cloudflare/bound.ts +++ b/alchemy/src/cloudflare/bound.ts @@ -20,6 +20,7 @@ import type { SecretRef as CloudflareSecretRef } from "./secret-ref.ts"; import type { Secret as CloudflareSecret } from "./secret.ts"; import type { VectorizeIndex as _VectorizeIndex } from "./vectorize-index.ts"; import type { VersionMetadata as _VersionMetadata } from "./version-metadata.ts"; +import type { VpcService as _VpcService } from "./vpc-service.ts"; import type { WorkerLoader as _WorkerLoader } from "./worker-loader.ts"; import type { WorkerRef } from "./worker-ref.ts"; import type { WorkerStub } from "./worker-stub.ts"; @@ -99,6 +100,8 @@ export type Bound = Obj & Rpc.DurableObjectBranded > - : T extends undefined - ? undefined - : Service; + : T extends _VpcService + ? Fetcher + : T extends undefined + ? undefined + : Service; diff --git a/alchemy/src/cloudflare/index.ts b/alchemy/src/cloudflare/index.ts index 9fc7be360..5067c9fe3 100644 --- a/alchemy/src/cloudflare/index.ts +++ b/alchemy/src/cloudflare/index.ts @@ -68,6 +68,7 @@ export * from "./vectorize-index.ts"; export * from "./vectorize-metadata-index.ts"; export * from "./version-metadata.ts"; export * from "./vite/vite.ts"; +export * from "./vpc-service.ts"; export * from "./website.ts"; export { WorkerLoader } from "./worker-loader.ts"; export * from "./worker-ref.ts"; diff --git a/alchemy/src/cloudflare/miniflare/build-worker-options.ts b/alchemy/src/cloudflare/miniflare/build-worker-options.ts index e16dc2e9f..a34ad825d 100644 --- a/alchemy/src/cloudflare/miniflare/build-worker-options.ts +++ b/alchemy/src/cloudflare/miniflare/build-worker-options.ts @@ -30,29 +30,26 @@ export interface MiniflareWorkerInput { cwd: string; } -type RemoteOnlyBindingType = - | "ai" - | "browser" - | "dispatch_namespace" - | "mtls_certificate" - | "vectorize"; -type RemoteOptionalBindingType = - | "d1" - | "images" - | "kv_namespace" - | "queue" - | "r2_bucket"; - type RemoteBinding = + // Supported remote bindings that are NOT a fetcher require the `raw` flag. | (Extract< WorkerBindingSpec, { - type: RemoteOnlyBindingType | RemoteOptionalBindingType; + type: + | "ai" + | "browser" + | "dispatch_namespace" + | "mtls_certificate" + | "vectorize" + | "d1" + | "images" + | "kv_namespace" + | "queue" + | "r2_bucket"; } - > & { - raw: true; - }) - | WorkerBindingService; + > & { raw: true }) + // Fetcher type bindings do not require the `raw` flag and will throw an error if it is present. + | Extract; type BaseWorkerOptions = { [K in keyof miniflare.WorkerOptions]: K extends @@ -314,6 +311,15 @@ export const buildWorkerOptions = async ( }; break; } + case "vpc_service": { + remoteBindings.push({ + type: "vpc_service", + name: key, + service_name: binding.name, + service_id: binding.serviceId, + }); + break; + } case "worker_loader": { (options.workerLoaders ??= {})[key] = {}; break; @@ -437,6 +443,12 @@ export const buildWorkerOptions = async ( remoteProxyConnectionString: remoteProxy.connectionString, }; break; + case "vpc_service": + (options.vpcServices ??= {})[binding.name] = { + service_id: binding.service_id, + remoteProxyConnectionString: remoteProxy.connectionString, + }; + break; default: { assertNever(binding); } diff --git a/alchemy/src/cloudflare/vpc-service.ts b/alchemy/src/cloudflare/vpc-service.ts new file mode 100644 index 000000000..dbc0b3ab5 --- /dev/null +++ b/alchemy/src/cloudflare/vpc-service.ts @@ -0,0 +1,269 @@ +import type { Context } from "../context"; +import { Resource } from "../resource.ts"; +import { isCloudflareApiError } from "./api-error.ts"; +import { extractCloudflareResult } from "./api-response.ts"; +import { + createCloudflareApi, + type CloudflareApi, + type CloudflareApiOptions, +} from "./api.ts"; +import type { Tunnel } from "./tunnel.ts"; + +export interface VpcServiceProps extends CloudflareApiOptions { + name?: string; + tcpPort?: number; + appProtocol?: string; + httpPort?: number; + httpsPort?: number; + host: + | VpcService.IPv4Host + | VpcService.IPv6Host + | VpcService.DualStackHost + | VpcService.HostnameHost; + adopt?: boolean; +} + +export declare namespace VpcService { + export type Host = IPv4Host | IPv6Host | DualStackHost | HostnameHost; + + export interface IPv4Host { + ipv4: string; + network: Network; + } + + export interface IPv6Host { + ipv6: string; + network: Network; + } + + export interface DualStackHost { + ipv4: string; + ipv6: string; + network: Network; + } + + export type Network = { tunnelId: string } | { tunnel: Tunnel }; + + export interface HostnameHost { + hostname: string; + resolverNetwork: Network & { resolverIps?: string[] }; + } +} + +export type VpcService = Omit & { + name: string; + serviceId: string; + createdAt: number; + updatedAt: number; + type: "vpc_service"; +}; + +export const VpcService = Resource( + "cloudflare::VpcService", + async function ( + this: Context, + id: string, + props: VpcServiceProps, + ): Promise { + const api = await createCloudflareApi(props); + if (this.phase === "delete") { + await deleteService(api, this.output.serviceId); + return this.destroy(); + } + const input: ConnectivityService.Input = { + name: props.name ?? this.scope.createPhysicalName(id), + type: "http", + tcp_port: props.tcpPort, + app_protocol: props.appProtocol, + http_port: props.httpPort, + https_port: props.httpsPort, + host: normalizeHost(props.host), + }; + switch (this.phase) { + case "create": { + const adopt = props.adopt ?? this.scope.adopt; + const service = await createService(api, input).catch(async (err) => { + if (isCloudflareApiError(err, { code: 5101 }) && adopt) { + const services = await listServices(api); + const service = services.find((s) => s.name === input.name); + if (service) { + return await updateService(api, service.service_id, input); + } + } + throw err; + }); + return formatOutput(service); + } + case "update": { + const service = await updateService(api, this.output.serviceId, input); + return formatOutput(service); + } + } + + function normalizeHost(host: VpcService.Host): ConnectivityService.Host { + if ("hostname" in host) { + return { + hostname: host.hostname, + resolver_network: normalizeNetwork(host.resolverNetwork), + }; + } + return { + ...host, + network: normalizeNetwork(host.network), + }; + } + + function normalizeNetwork( + network: T, + ): ConnectivityService.Network { + if ("tunnelId" in network) { + const { tunnelId, ...rest } = network; + return { tunnel_id: network.tunnelId, ...rest }; + } + const { tunnel, ...rest } = network; + return { tunnel_id: tunnel.tunnelId, ...rest }; + } + + function formatOutput(service: ConnectivityService): VpcService { + return { + name: service.name, + serviceId: service.service_id, + tcpPort: service.tcp_port, + appProtocol: service.app_protocol, + httpPort: service.http_port, + httpsPort: service.https_port, + host: + "hostname" in service.host + ? { + hostname: service.host.hostname, + resolverNetwork: { + tunnelId: service.host.resolver_network.tunnel_id, + resolverIps: service.host.resolver_network.resolver_ips, + }, + } + : { + ...service.host, + network: { tunnelId: service.host.network.tunnel_id }, + }, + createdAt: new Date(service.created_at).getTime(), + updatedAt: new Date(service.updated_at).getTime(), + type: "vpc_service", + }; + } + }, +); + +export async function createService( + api: CloudflareApi, + body: ConnectivityService.Input, +): Promise { + return await extractCloudflareResult( + `create connectivity service`, + api.post( + `/accounts/${api.accountId}/connectivity/directory/services`, + body, + ), + ); +} + +export async function deleteService( + api: CloudflareApi, + serviceId: string, +): Promise { + await extractCloudflareResult( + `delete connectivity service "${serviceId}"`, + api.delete( + `/accounts/${api.accountId}/connectivity/directory/services/${serviceId}`, + ), + ).catch((err) => { + if (!isCloudflareApiError(err, { status: 404 })) { + throw err; + } + }); +} + +export async function getService( + api: CloudflareApi, + serviceId: string, +): Promise { + return await extractCloudflareResult( + `get connectivity service "${serviceId}"`, + api.get( + `/accounts/${api.accountId}/connectivity/directory/services/${serviceId}`, + ), + ); +} + +export async function listServices( + api: CloudflareApi, +): Promise { + return await extractCloudflareResult( + `list connectivity services`, + api.get( + `/accounts/${api.accountId}/connectivity/directory/services?per_page=1000`, + ), + ); +} + +export async function updateService( + api: CloudflareApi, + serviceId: string, + body: ConnectivityService.Input, +): Promise { + return await extractCloudflareResult( + `update connectivity service "${serviceId}"`, + api.put( + `/accounts/${api.accountId}/connectivity/directory/services/${serviceId}`, + body, + ), + ); +} + +interface ConnectivityService extends ConnectivityService.Input { + service_id: string; + created_at: string; + updated_at: string; +} + +declare namespace ConnectivityService { + export interface Input { + name: string; + type: "http"; + tcp_port?: number; + app_protocol?: string; + http_port?: number; + https_port?: number; + host: Host; + } + + export type Host = IPv4Host | IPv6Host | DualStackHost | HostnameHost; + + export interface IPv4Host { + ipv4: string; + network: Network; + } + + export interface IPv6Host { + ipv6: string; + network: Network; + } + + export interface DualStackHost { + ipv4: string; + ipv6: string; + network: Network; + } + + export interface Network { + tunnel_id: string; + } + + export interface HostnameHost { + hostname: string; + resolver_network: ResolverNetwork; + } + + export interface ResolverNetwork extends Network { + resolver_ips?: string[]; + } +} diff --git a/alchemy/src/cloudflare/worker-metadata.ts b/alchemy/src/cloudflare/worker-metadata.ts index 971d81f4a..6fabbda83 100644 --- a/alchemy/src/cloudflare/worker-metadata.ts +++ b/alchemy/src/cloudflare/worker-metadata.ts @@ -610,6 +610,13 @@ export async function prepareWorkerMetadata( namespace_id: binding.namespace_id.toString(), simple: binding.simple, }); + } else if (binding.type === "vpc_service") { + meta.bindings.push({ + type: "vpc_service", + name: bindingName, + service_name: binding.name, + service_id: binding.serviceId, + }); } else { assertNever( binding, diff --git a/alchemy/src/cloudflare/wrangler.json.ts b/alchemy/src/cloudflare/wrangler.json.ts index e429b5c1b..f742db633 100644 --- a/alchemy/src/cloudflare/wrangler.json.ts +++ b/alchemy/src/cloudflare/wrangler.json.ts @@ -283,6 +283,7 @@ async function processBindings( const ratelimits: WranglerJsonConfig["ratelimits"] = []; const containers: WranglerJsonConfig["containers"] = []; const workerLoaders: WranglerJsonConfig["worker_loaders"] = []; + const vpcServices: WranglerJsonConfig["vpc_services"] = []; for (const eventSource of eventSources ?? []) { if (isQueueEventSource(eventSource)) { @@ -522,6 +523,12 @@ async function processBindings( workerLoaders.push({ binding: bindingName, }); + } else if (binding.type === "vpc_service") { + vpcServices.push({ + binding: bindingName, + service_id: binding.serviceId, + remote: true, + }); } else { console.log("binding", binding); return assertNever(binding); diff --git a/examples/cloudflare-vpc-service/alchemy.run.ts b/examples/cloudflare-vpc-service/alchemy.run.ts new file mode 100644 index 000000000..4fda35de3 --- /dev/null +++ b/examples/cloudflare-vpc-service/alchemy.run.ts @@ -0,0 +1,34 @@ +import alchemy from "alchemy"; +import { Tunnel, VpcService, Worker } from "alchemy/cloudflare"; + +const app = await alchemy("cloudflare-vpc-service"); + +export const tunnel = await Tunnel("tunnel", { + ingress: [ + { + service: "http://localhost:5173", + }, + ], +}); + +export const vpcService = await VpcService("vpc-service", { + httpPort: 5173, + host: { + hostname: "localhost", + resolverNetwork: { + tunnel, + resolverIps: ["127.0.0.1"], + }, + }, +}); + +export const worker = await Worker("worker", { + entrypoint: "./src/worker.ts", + bindings: { + VPC_SERVICE: vpcService, + }, +}); + +console.log(`Worker URL: ${worker.url}`); + +await app.finalize(); diff --git a/examples/cloudflare-vpc-service/package.json b/examples/cloudflare-vpc-service/package.json new file mode 100644 index 000000000..cc44021c6 --- /dev/null +++ b/examples/cloudflare-vpc-service/package.json @@ -0,0 +1,14 @@ +{ + "name": "cloudflare-vpc-service", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b", + "deploy": "alchemy deploy --env-file ../../.env", + "destroy": "alchemy destroy --env-file ../../.env", + "dev": "alchemy dev --env-file ../../.env" + }, + "dependencies": { + "alchemy": "workspace:*" + } +} diff --git a/examples/cloudflare-vpc-service/src/worker.ts b/examples/cloudflare-vpc-service/src/worker.ts new file mode 100644 index 000000000..adc91bcef --- /dev/null +++ b/examples/cloudflare-vpc-service/src/worker.ts @@ -0,0 +1,9 @@ +import type { worker } from "../alchemy.run.ts"; + +export default { + async fetch(request: Request, env: typeof worker.Env) { + const url = new URL(request.url); + const target = new URL(url.pathname + url.search, "http://localhost:5173"); + return await env.VPC_SERVICE.fetch(target); + }, +}; diff --git a/examples/cloudflare-vpc-service/tsconfig.json b/examples/cloudflare-vpc-service/tsconfig.json new file mode 100644 index 000000000..f50120541 --- /dev/null +++ b/examples/cloudflare-vpc-service/tsconfig.json @@ -0,0 +1,13 @@ +{ + "include": ["src/*.ts"], + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"], + "noEmit": true + }, + "references": [ + { + "path": "../../alchemy/tsconfig.json" + } + ] +} From 161bb57bcba51f1576a12c55571a0e3e82e15c11 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:12:13 -0500 Subject: [PATCH 2/4] comments --- alchemy/src/cloudflare/vpc-service.ts | 53 ++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/alchemy/src/cloudflare/vpc-service.ts b/alchemy/src/cloudflare/vpc-service.ts index dbc0b3ab5..043d14230 100644 --- a/alchemy/src/cloudflare/vpc-service.ts +++ b/alchemy/src/cloudflare/vpc-service.ts @@ -10,40 +10,90 @@ import { import type { Tunnel } from "./tunnel.ts"; export interface VpcServiceProps extends CloudflareApiOptions { + /** + * The name of the VPC service to create. + * + * @default ${app}-${stage}-${id} + */ name?: string; + /** + * The type of the VPC service. Currently only "http" is supported, but tcp will be supported in the future. + * + * @default "http" + */ + serviceType?: "http"; + /** + * The TCP port for the VPC service. + */ tcpPort?: number; + /** + * The application protocol for the VPC service. + */ appProtocol?: string; + /** + * The HTTP port for the VPC service. + * + * @default 80 + */ httpPort?: number; + /** + * The HTTPS port for the VPC service. + * + * @default 443 + */ httpsPort?: number; + /** + * The host for the VPC service. + */ host: | VpcService.IPv4Host | VpcService.IPv6Host | VpcService.DualStackHost | VpcService.HostnameHost; + /** + * Whether to adopt the VPC service if it already exists. + * + * @default false + */ adopt?: boolean; } export declare namespace VpcService { export type Host = IPv4Host | IPv6Host | DualStackHost | HostnameHost; + /** + * Represents a VPC service that is accessible via an IPv4 address. + */ export interface IPv4Host { ipv4: string; network: Network; } + /** + * Represents a VPC service that is accessible via an IPv6 address. + */ export interface IPv6Host { ipv6: string; network: Network; } + /** + * Represents a VPC service that is accessible via both IPv4 and IPv6 addresses. + */ export interface DualStackHost { ipv4: string; ipv6: string; network: Network; } + /** + * Represents a network that the VPC service is accessible via. This can be a tunnel ID or a `Tunnel` resource. + */ export type Network = { tunnelId: string } | { tunnel: Tunnel }; + /** + * Represents a VPC service that is accessible via a hostname. + */ export interface HostnameHost { hostname: string; resolverNetwork: Network & { resolverIps?: string[] }; @@ -72,7 +122,7 @@ export const VpcService = Resource( } const input: ConnectivityService.Input = { name: props.name ?? this.scope.createPhysicalName(id), - type: "http", + type: props.serviceType ?? "http", tcp_port: props.tcpPort, app_protocol: props.appProtocol, http_port: props.httpPort, @@ -128,6 +178,7 @@ export const VpcService = Resource( return { name: service.name, serviceId: service.service_id, + serviceType: service.type, tcpPort: service.tcp_port, appProtocol: service.app_protocol, httpPort: service.http_port, From b461a524be286c688c30fddf55196b66977824b6 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:43:26 -0500 Subject: [PATCH 3/4] docs --- .../docs/providers/cloudflare/vpc-service.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 alchemy-web/src/content/docs/providers/cloudflare/vpc-service.md diff --git a/alchemy-web/src/content/docs/providers/cloudflare/vpc-service.md b/alchemy-web/src/content/docs/providers/cloudflare/vpc-service.md new file mode 100644 index 000000000..b5cb7b620 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/cloudflare/vpc-service.md @@ -0,0 +1,241 @@ +--- +title: VpcService +description: Connect Cloudflare Workers to private network services securely through Cloudflare Tunnel. +--- + +[Cloudflare VPC Services](https://developers.cloudflare.com/workers-vpc/configuration/vpc-services/) enable Workers to securely access private network resources through Cloudflare Tunnel. + +## Minimal Example + +Create a VPC service that routes to a local hostname through a tunnel: + +```ts +import { Tunnel, VpcService } from "alchemy/cloudflare"; + +const tunnel = await Tunnel("my-tunnel", { + ingress: [{ service: "http://localhost:3000" }], +}); + +const vpcService = await VpcService("my-service", { + host: { + hostname: "localhost", + resolverNetwork: { + tunnel, + resolverIps: ["127.0.0.1"], + }, + }, +}); +``` + +## With IPv4 Address + +Route to a service using an IPv4 address: + +```ts +import { Tunnel, VpcService } from "alchemy/cloudflare"; + +const tunnel = await Tunnel("internal-tunnel", { + ingress: [{ service: "http://192.168.1.100:8080" }], +}); + +const vpcService = await VpcService("internal-api", { + host: { + ipv4: "192.168.1.100", + network: { tunnel }, + }, +}); +``` + +## With IPv6 Address + +Route to a service using an IPv6 address: + +```ts +import { Tunnel, VpcService } from "alchemy/cloudflare"; + +const tunnel = await Tunnel("ipv6-tunnel", { + ingress: [{ service: "http://[::1]:8080" }], +}); + +const vpcService = await VpcService("ipv6-service", { + host: { + ipv6: "::1", + network: { tunnel }, + }, +}); +``` + +## With Dual Stack + +Route to a service that supports both IPv4 and IPv6: + +```ts +import { Tunnel, VpcService } from "alchemy/cloudflare"; + +const tunnel = await Tunnel("dual-stack-tunnel", { + ingress: [{ service: "http://localhost:8080" }], +}); + +const vpcService = await VpcService("dual-stack-service", { + host: { + ipv4: "192.168.1.100", + ipv6: "::1", + network: { tunnel }, + }, +}); +``` + +## With Custom Ports + +Configure custom HTTP and HTTPS ports: + +```ts +import { Tunnel, VpcService } from "alchemy/cloudflare"; + +const tunnel = await Tunnel("custom-port-tunnel", { + ingress: [{ service: "http://localhost:5173" }], +}); + +const vpcService = await VpcService("dev-server", { + httpPort: 5173, + httpsPort: 5174, + host: { + hostname: "localhost", + resolverNetwork: { + tunnel, + resolverIps: ["127.0.0.1"], + }, + }, +}); +``` + +## Bind to a Worker + +Use a VPC service binding in a Cloudflare Worker to access private services: + +```ts +import { Tunnel, VpcService, Worker } from "alchemy/cloudflare"; + +const tunnel = await Tunnel("api-tunnel", { + ingress: [{ service: "http://internal-api:8080" }], +}); + +const vpcService = await VpcService("private-api", { + httpPort: 8080, + host: { + hostname: "internal-api", + resolverNetwork: { + tunnel, + resolverIps: ["10.0.0.1"], + }, + }, +}); + +const worker = await Worker("api-gateway", { + entrypoint: "./src/worker.ts", + bindings: { + PRIVATE_API: vpcService, + }, +}); +``` + +Then in your Worker code, use the binding to fetch from the private service: + +```ts +export default { + async fetch(request: Request, env: { PRIVATE_API: Fetcher }) { + // The VPC service routes this request through the tunnel + // to your private network + return await env.PRIVATE_API.fetch("http://internal-api/data"); + }, +}; +``` + +:::note +The URL passed to `fetch()` affects HTTP headers and SNI values, but the actual routing is determined by the VPC Service configuration (host and ports). +::: + +## With Existing Tunnel ID + +Reference an existing tunnel by its ID instead of using a Tunnel resource: + +```ts +import { VpcService } from "alchemy/cloudflare"; + +const vpcService = await VpcService("existing-tunnel-service", { + host: { + hostname: "internal.example.com", + resolverNetwork: { + tunnelId: "e6a0817c-79c5-40ca-9776-a1c019defe70", + resolverIps: ["10.0.0.53"], + }, + }, +}); +``` + +## Adopting Existing Services + +Take over management of an existing VPC service: + +```ts +import { Tunnel, VpcService } from "alchemy/cloudflare"; + +const tunnel = await Tunnel("adopted-tunnel", { + ingress: [{ service: "http://localhost:3000" }], +}); + +const vpcService = await VpcService("adopted-service", { + name: "existing-service-name", + adopt: true, + host: { + hostname: "localhost", + resolverNetwork: { tunnel }, + }, +}); +``` + +## Host Configuration Options + +### Hostname Host + +Use DNS resolution to reach the service: + +| Property | Type | Description | +|----------|------|-------------| +| `hostname` | `string` | The hostname to resolve | +| `resolverNetwork.tunnel` | `Tunnel` | The tunnel resource to use | +| `resolverNetwork.tunnelId` | `string` | Alternative: existing tunnel ID | +| `resolverNetwork.resolverIps` | `string[]` | Optional DNS resolver IPs | + +### IP Address Host + +Use a direct IP address: + +| Property | Type | Description | +|----------|------|-------------| +| `ipv4` | `string` | IPv4 address of the service | +| `ipv6` | `string` | IPv6 address of the service | +| `network.tunnel` | `Tunnel` | The tunnel resource to use | +| `network.tunnelId` | `string` | Alternative: existing tunnel ID | + +## Port Configuration + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `httpPort` | `number` | 80 | Port for HTTP traffic | +| `httpsPort` | `number` | 443 | Port for HTTPS traffic | +| `tcpPort` | `number` | - | Port for TCP traffic (future support) | +| `appProtocol` | `string` | - | Application protocol identifier | + +:::tip +VPC Services currently support HTTP service type. TCP support is planned for the future. +::: + +## Access Control + +To use VPC Services, users need the appropriate Cloudflare roles: + +- **Bind to services**: Requires "Connectivity Directory Bind" role +- **Create/manage services**: Requires "Connectivity Directory Admin" role + +If you use `alchemy login`, these scopes are included by default. From 887e222e985ce407f8685c64831b3835088440f9 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:50:56 -0500 Subject: [PATCH 4/4] tests --- alchemy/test/cloudflare/vpc-service.test.ts | 119 ++++++++++++++++++ .../cloudflare-vpc-service/alchemy.run.ts | 3 + 2 files changed, 122 insertions(+) create mode 100644 alchemy/test/cloudflare/vpc-service.test.ts diff --git a/alchemy/test/cloudflare/vpc-service.test.ts b/alchemy/test/cloudflare/vpc-service.test.ts new file mode 100644 index 000000000..0cc0a8c86 --- /dev/null +++ b/alchemy/test/cloudflare/vpc-service.test.ts @@ -0,0 +1,119 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { + type CloudflareApi, + createCloudflareApi, +} from "../../src/cloudflare/api.ts"; +import { Tunnel } from "../../src/cloudflare/tunnel.ts"; +import { + VpcService, + getService, + listServices, +} from "../../src/cloudflare/vpc-service.ts"; +import { destroy } from "../../src/destroy.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe("VpcService Resource", () => { + const testId = `${BRANCH_PREFIX}-vpc-svc`; + + test("create, update, and delete vpc service", async (scope) => { + const api = await createCloudflareApi(); + let tunnel: Tunnel | undefined; + let vpcService: VpcService | undefined; + + try { + // Create a minimal tunnel for the VPC service + tunnel = await Tunnel(`${testId}-tunnel`, { + name: `${testId}-tunnel`, + ingress: [{ service: "http://localhost:8080" }], + adopt: true, + }); + + // Create VPC service with hostname host + vpcService = await VpcService(testId, { + name: `${testId}-initial`, + httpPort: 8080, + host: { + hostname: "localhost", + resolverNetwork: { + tunnel, + }, + }, + adopt: true, + }); + + // Verify VPC service was created + expect(vpcService).toMatchObject({ + name: `${testId}-initial`, + serviceId: expect.any(String), + serviceType: "http", + httpPort: 8080, + host: { + hostname: "localhost", + resolverNetwork: { + tunnelId: tunnel.tunnelId, + }, + }, + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + type: "vpc_service", + }); + + // Verify service exists via API + const fetchedService = await getService(api, vpcService.serviceId); + expect(fetchedService).toMatchObject({ + name: `${testId}-initial`, + service_id: vpcService.serviceId, + type: "http", + http_port: 8080, + }); + + // Update the VPC service with new port + vpcService = await VpcService(testId, { + name: `${testId}-updated`, + httpPort: 3000, + httpsPort: 3001, + host: { + hostname: "localhost", + resolverNetwork: { + tunnel, + }, + }, + }); + + // Verify VPC service was updated + expect(vpcService).toMatchObject({ + name: `${testId}-updated`, + serviceId: expect.any(String), + httpPort: 3000, + httpsPort: 3001, + }); + + // Verify update via API + const updatedService = await getService(api, vpcService.serviceId); + expect(updatedService).toMatchObject({ + name: `${testId}-updated`, + http_port: 3000, + https_port: 3001, + }); + } catch (err) { + console.error("Test error:", err); + throw err; + } finally { + await destroy(scope); + await assertVpcServiceDeleted(api, vpcService?.serviceId); + } + }); +}); + +async function assertVpcServiceDeleted(api: CloudflareApi, serviceId?: string) { + if (serviceId) { + const services = await listServices(api); + expect(services.find((s) => s.service_id === serviceId)).toBeUndefined(); + } +} diff --git a/examples/cloudflare-vpc-service/alchemy.run.ts b/examples/cloudflare-vpc-service/alchemy.run.ts index 4fda35de3..1f2447b2a 100644 --- a/examples/cloudflare-vpc-service/alchemy.run.ts +++ b/examples/cloudflare-vpc-service/alchemy.run.ts @@ -9,6 +9,7 @@ export const tunnel = await Tunnel("tunnel", { service: "http://localhost:5173", }, ], + adopt: true, }); export const vpcService = await VpcService("vpc-service", { @@ -20,6 +21,7 @@ export const vpcService = await VpcService("vpc-service", { resolverIps: ["127.0.0.1"], }, }, + adopt: true, }); export const worker = await Worker("worker", { @@ -27,6 +29,7 @@ export const worker = await Worker("worker", { bindings: { VPC_SERVICE: vpcService, }, + adopt: true, }); console.log(`Worker URL: ${worker.url}`);