1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { describe, expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as NodeHttp from "node:http";
9import {
10 HOST_PROBE_PORT,
11 NEON_URL,
12 PLANETSCALE_MYSQL_URL,
13 PLANETSCALE_PG_URL,
14 PPG_URL,
15} from "./fixtures/hostreach/object.ts";
16import HostReachStack, { state } from "./fixtures/hostreach/stack.ts";
17
18const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
19 providers: Cloudflare.providers(),
20 state,
21 dev: true,
22});
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29// First request has to wait for the local runtime to `docker build` the image
30// and boot the container, so give it plenty of room.
31const HOOK_TIMEOUT = 300_000;
32const TEST_TIMEOUT = 240_000;
33
34const readinessSchedule = Schedule.min([
35 Schedule.exponential("500 millis"),
36 Schedule.spaced("3 seconds"),
37]);
38
39/**
40 * A host-side stand-in for a local dev database: an HTTP server bound to the
41 * developer machine's loopback, exactly like `@prisma/dev`'s direct Postgres
42 * endpoint. The container's env points at it via `http://localhost:<port>`.
43 */
44const hostServer = Effect.acquireRelease(
45 Effect.callback<NodeHttp.Server>((resume) => {
46 const server = NodeHttp.createServer((_req, res) => {
47 res.setHeader("content-type", "text/plain");
48 res.end("hello-from-host");
49 });
50 // Bind loopback only, like `@prisma/dev`. Docker Desktop's
51 // host-gateway reaches 127.0.0.1. Native Linux cannot SYN the
52 // bridge IP (UFW INPUT); the sidecar unix-socket-tunnels this
53 // port into the container netns instead.
54 server.listen(HOST_PROBE_PORT, "127.0.0.1", () =>
55 resume(Effect.succeed(server)),
56 );
57 server.on("error", (err) => resume(Effect.die(err)));
58 }),
59 (server) =>
60 Effect.callback<void>((resume) => {
61 server.close(() => resume(Effect.void));
62 }),
63);
64
65/**
66 * Regression test for alchemy-run/alchemy#1334: under `alchemy dev`, a
67 * container's env values that reference the developer machine's loopback
68 * (`localhost` / `127.0.0.1` — every local dev database emulator) must be
69 * reachable from inside the Docker container, where `localhost` is the
70 * container itself.
71 */
72describe(
73 "local container reaches host services",
74 {
75 tags: [
76 "provider:cloudflare",
77 "provider:cloudflare:container",
78 "provider:cloudflare:worker",
79 ],
80 },
81 () => {
82 const stack = beforeAll(deploy(HostReachStack), { timeout: HOOK_TIMEOUT });
83 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(HostReachStack), {
84 timeout: HOOK_TIMEOUT,
85 });
86
87 test(
88 "container env loopback URLs are rewritten and reach the host",
89 Effect.gen(function* () {
90 const { url } = yield* stack;
91 const client = yield* HttpClient.HttpClient;
92 yield* hostServer;
93
94 const get = (path: string) =>
95 client.get(new URL(path, url)).pipe(
96 Effect.flatMap((r) =>
97 r.status !== 200
98 ? Effect.fail(new Error(`not ready: ${r.status}`))
99 : r.text,
100 ),
101 Effect.timeout("30 seconds"),
102 Effect.retry({ schedule: readinessSchedule, times: 30 }),
103 );
104
105 // The env the container received: loopback hosts must be rewritten to
106 // a name that resolves to the host machine — and that name must still
107 // contain "localhost", because Prisma's client only speaks plain HTTP
108 // to `prisma+postgres://` hosts that look local (anything else flips
109 // it to TLS against a plain-HTTP local dev server).
110 const env = JSON.parse(yield* get("/env")) as {
111 TARGET_URL: string;
112 PPG_URL: string;
113 NEON_URL: string;
114 PLANETSCALE_PG_URL: string;
115 PLANETSCALE_MYSQL_URL: string;
116 };
117 expect(new URL(env.TARGET_URL).hostname).not.toBe("localhost");
118 expect(new URL(env.TARGET_URL).hostname).toContain("localhost");
119 expect(new URL(env.PPG_URL).hostname).not.toBe("localhost");
120 expect(new URL(env.PPG_URL).hostname).toContain("localhost");
121 // Everything else about the URLs is preserved.
122 expect(new URL(env.TARGET_URL).port).toBe(String(HOST_PROBE_PORT));
123 expect(new URL(env.PPG_URL).searchParams.get("api_key")).toBe(
124 new URL(PPG_URL).searchParams.get("api_key"),
125 );
126 // Cloud SQL URLs (Neon, PlanetScale) must not be rewritten — they are
127 // not loopback, and the container talks to them over the internet.
128 expect(env.NEON_URL).toBe(NEON_URL);
129 expect(env.PLANETSCALE_PG_URL).toBe(PLANETSCALE_PG_URL);
130 expect(env.PLANETSCALE_MYSQL_URL).toBe(PLANETSCALE_MYSQL_URL);
131
132 // The proof: the container fetches TARGET_URL (the host-side server)
133 // and reports what it got back.
134 const probe = JSON.parse(yield* get("/probe")) as {
135 status?: number;
136 body?: string;
137 error?: string;
138 };
139 expect(probe.error).toBeUndefined();
140 expect(probe.status).toBe(200);
141 expect(probe.body).toBe("hello-from-host");
142 }).pipe(Effect.scoped, logLevel),
143 { tags: ["local"], timeout: TEST_TIMEOUT },
144 );
145 },
146);