1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as pathe from "pathe";
11
12// `dev: true` runs local providers behind the RPC sidecar proxy by default,
13// matching the process topology of the real `alchemy dev` command. The
14// VpcService/Tunnel providers are mode-agnostic (single implementation), so
15// they deploy the REAL cloud resources even in dev — only the Worker is
16// emulated locally, and its `vpc_service` binding proxies to the real
17// service through the remote-binding bridge.
18const { test } = Test.make({
19 providers: Cloudflare.providers(),
20 dev: true,
21});
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28const cloudflaredBin = Bun.which("cloudflared");
29
30class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
31 status: number;
32}> {}
33
34const getJsonReady = (url: string) =>
35 Effect.gen(function* () {
36 const client = yield* HttpClient.HttpClient;
37 const res = yield* client.get(url).pipe(
38 Effect.flatMap((res) =>
39 res.status === 200
40 ? Effect.succeed(res)
41 : Effect.fail(new WorkerNotReady({ status: res.status })),
42 ),
43 Effect.retry({
44 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
45 schedule: Schedule.max([
46 Schedule.min([
47 Schedule.exponential("500 millis"),
48 Schedule.spaced("2 seconds"),
49 ]),
50 Schedule.recurs(10),
51 ]),
52 }),
53 );
54 return yield* res.json;
55 }).pipe(Effect.orDie);
56
57/**
58 * The dev worker binds a real VPC service two ways — the managed resource
59 * and a `lookup` data source — through the remote-binding bridge (there is
60 * nothing to emulate locally; before this the binding was rejected as
61 * unsupported in local mode). Pins that the worker boots with `vpc_service`
62 * bindings and both surface as Fetchers, and that a fetch round-trips
63 * through the bridge to the real service (no connector runs, so the service
64 * answers with an error — reaching it at all is the assertion).
65 */
66test.provider(
67 "dev worker binds a vpc service through the remote bridge",
68 (stack) =>
69 Effect.gen(function* () {
70 yield* stack.destroy();
71
72 const tunnelAndService = Effect.gen(function* () {
73 const tunnel = yield* Cloudflare.Tunnel.Tunnel("LocalVpcTunnel", {
74 ingress: [{ service: "http://localhost:8080" }],
75 adopt: true,
76 });
77 return yield* Cloudflare.VpcService.VpcService("LocalVpcSvc", {
78 httpPort: 8080,
79 host: {
80 hostname: "localhost",
81 resolverNetwork: { tunnelId: tunnel.tunnelId },
82 },
83 adopt: true,
84 });
85 });
86
87 const svc = yield* stack.deploy(tunnelAndService);
88 // Mode-agnostic provider: a real cloud id even in dev, never `dev:`.
89 expect(svc.serviceId).not.toMatch(/^dev:/);
90
91 const deployed = yield* stack.deploy(
92 Effect.gen(function* () {
93 const managed = yield* tunnelAndService;
94 const worker = yield* Cloudflare.Worker("vpc-local-worker", {
95 main: pathe.resolve(
96 import.meta.dirname,
97 "fixtures/vpc-local-worker.ts",
98 ),
99 env: {
100 VPC: managed,
101 VPC_LOOKUP: Cloudflare.VpcService.lookup({
102 serviceId: svc.serviceId,
103 }),
104 },
105 });
106 return { worker };
107 }),
108 );
109
110 expect(deployed.worker.url).toMatch(/^http:\/\/localhost:\d+$/);
111
112 // Both bindings surface as Fetchers inside the local workerd.
113 for (const binding of ["VPC", "VPC_LOOKUP"]) {
114 const body = (yield* getJsonReady(
115 `${deployed.worker.url}/type?binding=${binding}`,
116 )) as { type: string };
117 expect(body.type).toBe("function");
118 }
119
120 // A fetch through the binding round-trips the remote bridge to the
121 // real VPC service. Without a running connector the service cannot
122 // reach an origin, so any JSON answer (an upstream error status or a
123 // fetcher error) proves the bridge path; a broken bridge never
124 // produces one.
125 const proxied = (yield* getJsonReady(
126 `${deployed.worker.url}/proxy?binding=VPC`,
127 )) as { status?: number; error?: string };
128 expect(proxied.status !== undefined || proxied.error !== undefined).toBe(
129 true,
130 );
131
132 yield* stack.destroy();
133 }).pipe(logLevel),
134 {
135 tags: [
136 "provider:cloudflare",
137 "provider:cloudflare:tunnel",
138 "provider:cloudflare:vpcservice",
139 "provider:cloudflare:worker",
140 "live",
141 ],
142 timeout: 180_000,
143 },
144);
145
146const ORIGIN_PORT = 18787;
147
148/**
149 * Full end-to-end: a local HTTP origin, a real `cloudflared` connector for
150 * the tunnel, and the dev worker fetching the origin through
151 * `env.VPC.fetch()` — local workerd → remote bridge → real VPC service →
152 * tunnel → connector on this machine → local origin. Gated on the
153 * `cloudflared` binary being installed.
154 */
155test.provider.skipIf(!cloudflaredBin)(
156 "dev worker fetches a local origin through a cloudflared connector",
157 (stack) =>
158 Effect.gen(function* () {
159 yield* stack.destroy();
160
161 const tunnelAndService = Effect.gen(function* () {
162 const tunnel = yield* Cloudflare.Tunnel.Tunnel("E2eVpcTunnel", {
163 ingress: [{ service: `http://localhost:${ORIGIN_PORT}` }],
164 adopt: true,
165 });
166 const service = yield* Cloudflare.VpcService.VpcService("E2eVpcSvc", {
167 httpPort: ORIGIN_PORT,
168 host: {
169 ipv4: "127.0.0.1",
170 network: { tunnelId: tunnel.tunnelId },
171 },
172 adopt: true,
173 });
174 return { tunnel, service };
175 });
176
177 const { tunnel } = yield* stack.deploy(tunnelAndService);
178
179 const deployed = yield* stack.deploy(
180 Effect.gen(function* () {
181 const { service } = yield* tunnelAndService;
182 const worker = yield* Cloudflare.Worker("vpc-e2e-worker", {
183 main: pathe.resolve(
184 import.meta.dirname,
185 "fixtures/vpc-local-worker.ts",
186 ),
187 env: { VPC: service },
188 });
189 return { worker };
190 }),
191 );
192
193 yield* Effect.scoped(
194 Effect.gen(function* () {
195 // Local origin the tunnel connector will proxy to.
196 yield* Effect.acquireRelease(
197 Effect.sync(() =>
198 Bun.serve({
199 port: ORIGIN_PORT,
200 fetch: (req) =>
201 new Response(`origin:${new URL(req.url).pathname}`),
202 }),
203 ),
204 (server) => Effect.sync(() => void server.stop(true)),
205 );
206
207 // The connector for the tunnel, running on this machine.
208 yield* Effect.acquireRelease(
209 Effect.sync(() =>
210 Bun.spawn(
211 [
212 cloudflaredBin!,
213 "tunnel",
214 "run",
215 "--token",
216 Redacted.value(tunnel.token),
217 ],
218 { stdout: "ignore", stderr: "ignore" },
219 ),
220 ),
221 (proc) => Effect.sync(() => proc.kill()),
222 );
223
224 // Poll the worker until the fetch lands on the origin — the
225 // connector takes a few seconds to register, and the service→edge
226 // wiring can lag behind it.
227 const body = yield* getJsonReady(
228 `${deployed.worker.url}/proxy?binding=VPC&url=http://vpc/hello`,
229 ).pipe(
230 Effect.repeat({
231 schedule: Schedule.spaced("3 seconds"),
232 until: (b) => (b as { status?: number }).status === 200,
233 times: 30,
234 }),
235 );
236
237 expect(body).toEqual({ status: 200, body: "origin:/hello" });
238 }),
239 );
240
241 yield* stack.destroy();
242 }).pipe(logLevel),
243 {
244 tags: [
245 "provider:cloudflare",
246 "provider:cloudflare:tunnel",
247 "provider:cloudflare:vpcservice",
248 "provider:cloudflare:worker",
249 "live",
250 ],
251 timeout: 300_000,
252 },
253);