| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import { expect } from "alchemy-test"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import { MinimumLogLevel } from "effect/References"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | import * as HttpClient from "effect/http/HttpClient"; |
| 10 | import * 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. |
| 18 | const { test } = Test.make({ |
| 19 | providers: Cloudflare.providers(), |
| 20 | dev: true, |
| 21 | }); |
| 22 | |
| 23 | const logLevel = Effect.provideService( |
| 24 | MinimumLogLevel, |
| 25 | process.env.DEBUG ? "Debug" : "Info", |
| 26 | ); |
| 27 | |
| 28 | const cloudflaredBin = Bun.which("cloudflared"); |
| 29 | |
| 30 | class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ |
| 31 | status: number; |
| 32 | }> {} |
| 33 | |
| 34 | const 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 | */ |
| 66 | test.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 | |
| 146 | const 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 | */ |
| 155 | test.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 | ); |