1import * as Config from "effect/Config";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import type * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12import * as NodeHttp from "node:http";
13import * as NodeStream from "node:stream";
14import {
15 attachLoopbackNetnsForwarder,
16 CONTAINER_LOOPBACK_ALIAS,
17 containerIdFromPath,
18 detachLoopbackNetnsForwarder,
19 ensureLoopbackUnixSockets,
20 isContainerStartPath,
21 loopbackPortsFromEnv,
22 mergeSidecarLoopbackHostConfig,
23 ufwAllowHint,
24 usesUnixSocketLoopback,
25} from "./DockerLoopback.ts";
26import { getAddress } from "./internal/get-address.ts";
27import { ConfigError, SystemError } from "./RuntimeError.shared.ts";
28import type * as WorkerdConfig from "./workerd/Config.ts";
29
30export { CONTAINER_LOOPBACK_ALIAS };
31
32export class Docker extends Context.Service<
33 Docker,
34 {
35 readonly getWorkerdDockerConfiguration: Effect.Effect<
36 WorkerdConfig.Worker_ContainerEngine,
37 SystemError
38 >;
39 readonly generateImageTag: (className: string, suffix?: string) => string;
40 readonly registerImageEnv: (
41 className: string,
42 tag: string,
43 env: Record<string, string>,
44 ) => Effect.Effect<string, never, Scope.Scope>;
45 readonly build: (
46 tag: string,
47 image: ContainerImage.Build,
48 ) => Effect.Effect<void, SystemError>;
49 readonly pull: (
50 tag: string,
51 image: ContainerImage.Pull,
52 ) => Effect.Effect<void, SystemError>;
53 readonly validate: (tag: string) => Effect.Effect<void, ConfigError>;
54 readonly removeImageTag: (tag: string) => Effect.Effect<void>;
55 readonly removeContainer: (tag: string) => Effect.Effect<void, SystemError>;
56 }
57>()("cloudflare-runtime/Docker") {}
58
59export type ContainerImage =
60 | ContainerImage.Build
61 | ContainerImage.Pull
62 | ContainerImage.Ref;
63
64export declare namespace ContainerImage {
65 interface Base {
66 readonly env?: Record<string, string>;
67 }
68 export interface Build extends Base {
69 readonly dockerfile: string;
70 readonly context?: string;
71 readonly buildArgs?: Record<string, string>;
72 }
73 export interface Pull extends Base {
74 readonly imageUri: string;
75 }
76 export interface Ref extends Base {
77 readonly tag: string;
78 }
79}
80
81const DEFAULT_DOCKER_HOST =
82 process.platform === "win32"
83 ? "//./pipe/docker_engine"
84 : "unix:///var/run/docker.sock";
85const DEV_CONTAINER_PREFIX = "alchemy-dev";
86
87const DockerHost = Config.String("DOCKER_HOST");
88const DockerBin = Config.String("DOCKER_BIN").pipe(
89 Config.orElse(() => Config.succeed("docker")),
90);
91const ContainerEgressInterceptorImage = Config.String(
92 "CONTAINER_EGRESS_INTERCEPTOR_IMAGE",
93).pipe(
94 Config.orElse(() =>
95 Config.succeed(
96 "cloudflare/proxy-everything:3cb1195@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8",
97 ),
98 ),
99);
100
101/**
102 * Docker's containerd image store rejects combined `repo:tag@digest` pull
103 * refs ("cannot overwrite digest"); the digest fully pins the image, so the
104 * tag is dropped when both are present.
105 */
106export const toPullRef = (imageUri: string) =>
107 imageUri.replace(/:[^@/]+(?=@sha256:)/, "");
108
109/**
110 * Stderr signatures of a docker CLI that cannot run our build. Image builds
111 * use BuildKit flags (`--load`, `--provenance=false`), which a CLI without
112 * the buildx plugin rejects outright. Its stderr names the flag rather than
113 * the missing component, and several distros package the two separately
114 * (Arch/CachyOS `docker` vs `docker-buildx`, Debian `docker.io` vs
115 * `docker-buildx`), so this is a first-run trap rather than a broken setup.
116 */
117const BUILDKIT_MISSING_MARKERS = [
118 "unknown flag: --load",
119 "unknown flag: --provenance",
120 "'buildx' is not a docker command",
121 "buildx component is missing",
122];
123
124/** Names the missing buildx plugin when docker's own stderr only names the flag. */
125export const buildFailureHint = (stderr: string): string | undefined =>
126 BUILDKIT_MISSING_MARKERS.some((marker) => stderr.includes(marker))
127 ? "This docker CLI has no BuildKit (buildx) plugin, which building container images requires. Install it and retry — e.g. `pacman -S docker-buildx` (Arch/CachyOS), `apt install docker-buildx` (Debian/Ubuntu), or Docker Desktop, which bundles it."
128 : undefined;
129
130/**
131 * Matches a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`)
132 * where it denotes a connection target inside an env value: at the start of
133 * the value, after a `scheme://` (with optional userinfo), after `=` (DSN
134 * keyword form, `host=localhost`), or after whitespace/comma/semicolon
135 * delimiters — and followed by a port, path, delimiter, or the end.
136 */
137const LOOPBACK_HOST =
138 /(^|[\s,;=]|\/\/(?:[^/\s@]*@)?)(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?=[:/?#]|[\s,;]|$)/g;
139
140/**
141 * Rewrite loopback hosts in a container env value to
142 * {@link CONTAINER_LOOPBACK_ALIAS} so the value keeps meaning "the developer's
143 * machine" from inside the container. Prisma's `prisma+postgres://` client
144 * only speaks plain HTTP when the host looks local (contains `localhost`).
145 * Applied at container create; production URLs are cloud hosts, so this
146 * only fires against local emulators.
147 *
148 * Native Linux: the alias is `/etc/hosts`-mapped to `127.0.0.1` in the
149 * sidecar netns, and a unix-socket tunnel reaches the host process — a
150 * SYN to Docker's bridge IP would hit UFW INPUT. Docker Desktop: mapped
151 * to `host-gateway`, which already forwards to host loopback.
152 */
153export const rewriteLoopbackHosts = (value: string) =>
154 value.replace(LOOPBACK_HOST, `$1${CONTAINER_LOOPBACK_ALIAS}`);
155
156/**
157 * Merge workerd's create-body `Env` with the deployment env alchemy injects,
158 * rewriting loopback hosts and **replacing by name**.
159 *
160 * Appending would leave the original `DATABASE_URL=…127.0.0.1…` in place and
161 * add a rewritten copy. glibc/`os.Getenv` (Go, C, Python, Node on Linux)
162 * return the first match, so the container would still dial `127.0.0.1`
163 * inside its own netns.
164 */
165export const mergeContainerCreateEnv = (
166 originalEnv: ReadonlyArray<string> | undefined,
167 imageEnv: Record<string, string> | undefined,
168): string[] => {
169 const order: string[] = [];
170 const values = new Map<string, string | undefined>();
171 const set = (name: string, value: string | undefined) => {
172 if (!values.has(name)) order.push(name);
173 values.set(name, value);
174 };
175 for (const entry of originalEnv ?? []) {
176 const eq = entry.indexOf("=");
177 if (eq === -1) {
178 set(entry, undefined);
179 } else {
180 set(entry.slice(0, eq), rewriteLoopbackHosts(entry.slice(eq + 1)));
181 }
182 }
183 for (const [name, value] of Object.entries(imageEnv ?? {})) {
184 set(name, rewriteLoopbackHosts(value));
185 }
186 return order.map((name) => {
187 const value = values.get(name);
188 return value === undefined ? name : `${name}=${value}`;
189 });
190};
191
192export const DockerLive = Layer.effect(
193 Docker,
194 Effect.gen(function* () {
195 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
196 const fs = yield* FileSystem.FileSystem;
197 const path = yield* Path.Path;
198
199 const bin = yield* DockerBin;
200 const containerEgressInterceptorImage =
201 yield* ContainerEgressInterceptorImage;
202 const registeredImages = new Map<
203 string,
204 { tag: string; env: Record<string, string> }
205 >();
206
207 const registeredLoopbackPorts = () => {
208 const ports = new Set<number>();
209 for (const { env } of registeredImages.values()) {
210 for (const port of loopbackPortsFromEnv(env)) ports.add(port);
211 }
212 return [...ports];
213 };
214
215 const getSocketPathFromContext = () =>
216 ChildProcess.make(bin, ["context", "ls", "--format", "json"], {
217 stdin: "ignore",
218 stdout: "pipe",
219 stderr: "pipe",
220 detached: false,
221 }).pipe(
222 spawner.spawn,
223 Effect.flatMap((child) =>
224 child.stdout.pipe(
225 Stream.decodeText,
226 Stream.splitLines,
227 Stream.filter((line) => line.trim() !== ""),
228 Stream.map(
229 (line) =>
230 JSON.parse(line) as {
231 Current: boolean;
232 DockerEndpoint: string;
233 },
234 ),
235 Stream.runCollect,
236 Effect.flatMap((items) => {
237 const endpoint = items.find(
238 (item) => item.Current,
239 )?.DockerEndpoint;
240 return endpoint
241 ? Effect.succeed(endpoint)
242 : Effect.fail(
243 new ConfigError({
244 subtag: "DockerHostNotFound",
245 message: "Docker host not found",
246 }),
247 );
248 }),
249 ),
250 ),
251 Effect.scoped,
252 );
253
254 const makeDockerProxyServer = (socketPath: string) =>
255 NodeHttp.createServer(async (req, res) => {
256 const isCreateRequest =
257 req.method === "POST" && req.url?.startsWith("/containers/create");
258 // workerd creates two containers per instance: the user container and
259 // a `<name>-proxy` networking sidecar whose namespace the user
260 // container joins (`NetworkMode: container:<sidecar>`) — so the
261 // sidecar's /etc/hosts is what the user container resolves against.
262 const isSidecarCreateRequest =
263 isCreateRequest && req.url!.endsWith("-proxy");
264 if (isCreateRequest && !isSidecarCreateRequest) {
265 const original = await extractJsonBody<{
266 Image: string;
267 Env: Array<string>;
268 }>(req);
269 const image = registeredImages.get(original.Image);
270 const transformed = JSON.stringify({
271 ...original,
272 Image: image?.tag ?? original.Image,
273 Env: mergeContainerCreateEnv(original.Env, image?.env),
274 });
275 const proxy = sendProxyRequest({
276 socketPath,
277 path: req.url,
278 method: req.method,
279 headers: {
280 ...req.headers,
281 "content-length": Buffer.byteLength(transformed).toString(),
282 },
283 res,
284 });
285 proxy.end(transformed);
286 } else if (isSidecarCreateRequest) {
287 // Shared netns with the user container. Docker Desktop maps the
288 // alias through host-gateway (reaches host 127.0.0.1). Native
289 // Linux maps it to 127.0.0.1 in this netns and bind-mounts unix
290 // sockets; a SYN to the bridge IP is host INPUT (UFW).
291 const original = await extractJsonBody<{
292 HostConfig?: { ExtraHosts?: Array<string>; Binds?: Array<string> };
293 }>(req);
294 const ports = registeredLoopbackPorts();
295 ensureLoopbackUnixSockets(ports);
296 const transformed = JSON.stringify({
297 ...original,
298 HostConfig: mergeSidecarLoopbackHostConfig(
299 original.HostConfig,
300 ports,
301 ),
302 });
303 const proxy = sendProxyRequest({
304 socketPath,
305 path: req.url,
306 method: req.method,
307 headers: {
308 ...req.headers,
309 "content-length": Buffer.byteLength(transformed).toString(),
310 },
311 res,
312 });
313 proxy.end(transformed);
314 } else if (req.method === "POST" && isContainerStartPath(req.url)) {
315 const id = containerIdFromPath(req.url);
316 const proxy = sendProxyRequest({
317 socketPath,
318 path: req.url,
319 method: req.method,
320 headers: req.headers,
321 res,
322 afterSuccess:
323 id === undefined
324 ? undefined
325 : () =>
326 attachSidecarLoopback(
327 socketPath,
328 id,
329 registeredLoopbackPorts(),
330 ),
331 });
332 req.pipe(proxy, { end: true });
333 } else if (req.method === "DELETE") {
334 const id = containerIdFromPath(req.url);
335 if (id !== undefined) detachLoopbackNetnsForwarder(id);
336 const proxy = sendProxyRequest({
337 socketPath,
338 path: req.url,
339 method: req.method,
340 headers: req.headers,
341 res,
342 });
343 req.pipe(proxy, { end: true });
344 } else {
345 const proxy = sendProxyRequest({
346 socketPath,
347 path: req.url,
348 method: req.method,
349 headers: req.headers,
350 res,
351 });
352 req.pipe(proxy, { end: true });
353 }
354 });
355
356 const run = (
357 args: Array<string>,
358 stdin: ChildProcess.CommandInput = "ignore",
359 ) =>
360 ChildProcess.make(bin, args, {
361 stdin,
362 stdout: "pipe",
363 stderr: "pipe",
364 detached: false,
365 }).pipe(
366 spawner.spawn,
367 Effect.flatMap((child) =>
368 Effect.all(
369 {
370 exitCode: child.exitCode,
371 stdout: child.stdout.pipe(
372 Stream.decodeText,
373 Stream.tap(Effect.logDebug),
374 Stream.mkString,
375 ),
376 stderr: child.stderr.pipe(
377 Stream.decodeText,
378 Stream.tap(Effect.logDebug),
379 Stream.mkString,
380 ),
381 },
382 { concurrency: "unbounded" },
383 ),
384 ),
385 Effect.scoped,
386 );
387
388 /**
389 * `run` resolves with the command's exit code instead of failing on it
390 * (some callers, e.g. `inspect`, treat a non-zero exit as a legitimate
391 * "not present" answer). Every command whose failure actually matters
392 * therefore has to check the code itself: without this, a `docker build`
393 * that exits 1 is indistinguishable from a successful build, the image
394 * is never created, and the only symptom the user ever sees is workerd
395 * reporting `Container exited while waiting for port <port>` on a loop.
396 */
397 const ensureExitZero = <E>(
398 result: { exitCode: number; stdout: string; stderr: string },
399 onNonZero: (result: {
400 exitCode: number;
401 stdout: string;
402 stderr: string;
403 }) => E,
404 ): Effect.Effect<void, E> =>
405 result.exitCode === 0 ? Effect.void : Effect.fail(onNonZero(result));
406
407 /**
408 * `platform` is omitted for the egress interceptor: it is a host-side
409 * sidecar, not a deployable image, so Docker must resolve the
410 * host-native variant. Forcing `linux/amd64` there runs it under
411 * emulation on arm64 hosts, where its transparent-proxy setup dies at
412 * startup with `Fatal error: setsockoptint: protocol not available`.
413 * Deployable user images pass `linux/amd64` explicitly, because that is
414 * the architecture Cloudflare's container runtime executes.
415 */
416 const pull = ({
417 imageUri,
418 platform,
419 }: ContainerImage.Pull & { readonly platform?: string }) =>
420 run([
421 "pull",
422 toPullRef(imageUri),
423 ...(platform ? ["--platform", platform] : []),
424 ]).pipe(
425 Effect.mapError(
426 (cause) =>
427 new SystemError({
428 subtag: "DockerPullFailed",
429 message: `Failed to pull image "${imageUri}".`,
430 hint: "Ensure Docker is running and the image is available.",
431 detail: { bin, imageUri },
432 cause,
433 }),
434 ),
435 Effect.flatMap((result) => {
436 if (result.exitCode !== 0) {
437 return Effect.fail(
438 new SystemError({
439 subtag: "DockerPullFailed",
440 message: `Failed to pull image "${imageUri}".`,
441 hint: "Ensure Docker is running and the image is available.",
442 detail: {
443 bin,
444 imageUri,
445 exitCode: result.exitCode,
446 stdout: result.stdout,
447 stderr: result.stderr,
448 },
449 }),
450 );
451 }
452 return Effect.succeed(result.stdout);
453 }),
454 );
455
456 const inspect = (tag: string, format: string) =>
457 Effect.map(
458 run(["image", "inspect", tag, "--format", format]),
459 (result) => result.stdout,
460 );
461
462 const list = (ancestor: string) =>
463 run([
464 "ps",
465 "-a",
466 "--no-trunc",
467 "--filter",
468 `ancestor=${ancestor}`,
469 "--format",
470 "{{.ID}} {{.Names}} {{.Image}}",
471 ]).pipe(
472 Effect.map((result) =>
473 result.stdout
474 .split("\n")
475 .filter((line) => line.trim() !== "")
476 .map((line) => {
477 const [id, name, image] = line.split(" ");
478 return { id, name, image };
479 })
480 .filter((container) => container.image === ancestor),
481 ),
482 );
483
484 const docker = yield* Effect.zipWith(
485 DockerHost.pipe(
486 Effect.catchTag("ConfigError", getSocketPathFromContext),
487 Effect.orElseSucceed(() => DEFAULT_DOCKER_HOST),
488 Effect.flatMap((socketPath) => {
489 const server = makeDockerProxyServer(socketPath);
490 server.listen(0);
491 return getAddress(server);
492 }),
493 ),
494 // Skip the eager pull when the interceptor image is already present
495 // locally. `CONTAINER_EGRESS_INTERCEPTOR_IMAGE` can point at a
496 // local-only tag that exists in the Docker daemon but resolves in no
497 // registry (e.g. a locally-built dev image) — an unconditional
498 // `docker pull` there fails, this detached fiber dies, and every
499 // caller of `getWorkerdDockerConfiguration` (joined on first use)
500 // fails with it. `docker image inspect` prints the image id when
501 // present and empty stdout when absent (`run` reports the non-zero
502 // exit rather than failing the effect), so only pull when the image
503 // is genuinely missing. Any failure to even run `inspect` (unlike
504 // `pull`, it doesn't normalize its error channel) falls back to the
505 // pre-existing pull behavior rather than failing here.
506 inspect(containerEgressInterceptorImage, "{{.Id}}").pipe(
507 Effect.orElseSucceed(() => undefined),
508 Effect.flatMap((imageId) =>
509 imageId?.trim()
510 ? Effect.void
511 : pull({ imageUri: containerEgressInterceptorImage }),
512 ),
513 ),
514 (socketPath) => ({
515 localDocker: {
516 socketPath,
517 containerEgressInterceptorImage,
518 },
519 }),
520 { concurrent: true },
521 ).pipe(
522 Effect.forkDetach({ startImmediately: false, uninterruptible: true }),
523 );
524
525 return Docker.of({
526 getWorkerdDockerConfiguration: Fiber.join(docker),
527 registerImageEnv: (className, tag, env) => {
528 const alias = generateImageTag(className);
529 return Effect.acquireRelease(
530 Effect.sync(() => {
531 registeredImages.set(alias, { tag, env });
532 ensureLoopbackUnixSockets(loopbackPortsFromEnv(env));
533 }),
534 () => Effect.sync(() => registeredImages.delete(alias)),
535 ).pipe(Effect.as(alias));
536 },
537 generateImageTag,
538 build: (tag, image) =>
539 Effect.suspend(() => {
540 const args = [
541 "build",
542 "--load",
543 "-t",
544 tag,
545 "--platform",
546 "linux/amd64",
547 "--provenance=false",
548 ...Object.entries(image.buildArgs ?? {}).map(
549 ([name, value]) => `--build-arg ${name}=${value}`,
550 ),
551 "-f",
552 "-",
553 path.resolve(image.context ?? path.dirname(image.dockerfile)),
554 ];
555 return run(
556 args,
557 fs.stream(
558 image.context
559 ? path.resolve(image.context, image.dockerfile)
560 : path.resolve(image.dockerfile),
561 ),
562 ).pipe(
563 Effect.withLogSpan(`docker: build ${tag}`),
564 Effect.mapError(
565 (cause) =>
566 new SystemError({
567 subtag: "DockerBuildFailed",
568 message: `Failed to build image "${tag}".`,
569 cause,
570 }),
571 ),
572 Effect.flatMap((result) =>
573 ensureExitZero(
574 result,
575 ({ exitCode, stdout, stderr }) =>
576 new SystemError({
577 subtag: "DockerBuildFailed",
578 message: `Failed to build image "${tag}".`,
579 hint: buildFailureHint(stderr),
580 detail: { bin, tag, exitCode, stdout, stderr },
581 }),
582 ),
583 ),
584 );
585 }),
586 pull: (tag, image) =>
587 pull({ ...image, platform: "linux/amd64" }).pipe(
588 Effect.andThen(
589 run(["tag", image.imageUri, tag]).pipe(
590 Effect.mapError(
591 (cause) =>
592 new SystemError({
593 subtag: "DockerTagFailed",
594 message: `Failed to tag image "${image.imageUri}" as "${tag}".`,
595 cause,
596 }),
597 ),
598 Effect.flatMap((result) =>
599 ensureExitZero(
600 result,
601 ({ exitCode, stdout, stderr }) =>
602 new SystemError({
603 subtag: "DockerTagFailed",
604 message: `Failed to tag image "${image.imageUri}" as "${tag}".`,
605 detail: {
606 bin,
607 imageUri: image.imageUri,
608 tag,
609 exitCode,
610 stdout,
611 stderr,
612 },
613 }),
614 ),
615 ),
616 ),
617 ),
618 Effect.withLogSpan(`docker: pull ${image.imageUri}`),
619 Effect.asVoid,
620 ),
621 validate: (tag) =>
622 inspect(tag, "{{ len .Config.ExposedPorts }}").pipe(
623 Effect.withLogSpan(`docker: inspect ${tag} for exposed ports`),
624 Effect.orElseSucceed(() => "0"),
625 Effect.flatMap((output) =>
626 output === "0"
627 ? Effect.fail(
628 new ConfigError({
629 subtag: "ContainerNoExposedPorts",
630 message: `The container for "${tag}" does not expose any ports.`,
631 hint: "Add an EXPOSE instruction to the Dockerfile for any ports you intend to connect to.",
632 }),
633 )
634 : Effect.void,
635 ),
636 ),
637 // Untag ONLY the given tag (used by each runtime start's finalizer for
638 // the tag it created). Deliberately does not guess at sibling
639 // "<name>:<otherSuffix>" tags: concurrent/successive dev sessions of
640 // the same container class each hold their own random-suffix tag on
641 // the same underlying image, and pruning siblings untags an image a
642 // live workerd still needs (its container creates then fail forever).
643 removeImageTag: (tag) =>
644 Effect.asVoid(run(["rmi", tag])).pipe(
645 Effect.withLogSpan(`docker: remove image tag ${tag}`),
646 Effect.ignore,
647 ),
648 removeContainer: (tag) =>
649 list(tag).pipe(
650 Effect.flatMap((containers) => {
651 if (containers.length === 0) return Effect.void;
652 return Effect.asVoid(
653 run([
654 "rm",
655 "--force",
656 ...containers.flatMap((container) => [
657 container.id,
658 `${container.name}-proxy`,
659 ]),
660 ]),
661 );
662 }),
663 Effect.withLogSpan(`docker: remove containers for ${tag}`),
664 Effect.mapError(
665 (cause) =>
666 new SystemError({
667 subtag: "DockerRemoveContainerFailed",
668 message: `Failed to remove containers for "${tag}".`,
669 cause,
670 }),
671 ),
672 ),
673 });
674 }),
675);
676
677const generateImageTag = (className: string, suffix?: string) =>
678 `${DEV_CONTAINER_PREFIX}/${className.toLowerCase()}:${suffix ?? crypto.randomUUID().slice(0, 8)}`;
679
680const sendProxyRequest = (input: {
681 socketPath: string;
682 path: string | undefined;
683 method: string | undefined;
684 headers: NodeHttp.OutgoingHttpHeaders;
685 res: NodeHttp.ServerResponse;
686 afterSuccess?: () => Promise<void>;
687}) => {
688 // `transfer-encoding` is a hop-by-hop header and must not be forwarded
689 // verbatim. workerd sends its `DELETE /containers/<name>-proxy?force=true`
690 // cleanup request with `transfer-encoding: chunked` and an empty body;
691 // Bun's `node:http` client hangs indefinitely on such a request to a
692 // unix socket (it never flushes the terminating zero-chunk), so the
693 // docker daemon never responds and workerd blocks forever before it can
694 // create the container. Strip the header and let the runtime derive the
695 // framing from the body we actually write. (Node tolerates it, Bun does
696 // not — and Alchemy dev runs the runtime under Bun.)
697 delete input.headers["transfer-encoding"];
698 const req = NodeHttp.request(
699 {
700 socketPath: input.socketPath.replace(/^unix:/, ""),
701 path: input.path,
702 method: input.method,
703 headers: input.headers,
704 },
705 (res) => {
706 delete res.headers["transfer-encoding"];
707 const succeed =
708 (res.statusCode ?? 500) < 300 && input.afterSuccess !== undefined;
709 if (!succeed) {
710 input.res.writeHead(res.statusCode || 500, res.headers);
711 res.pipe(input.res, { end: true });
712 return;
713 }
714 const chunks: Array<Buffer> = [];
715 res.on("data", (chunk) => chunks.push(chunk));
716 res.on("end", () => {
717 void input.afterSuccess!().finally(() => {
718 const status = res.statusCode || 500;
719 input.res.writeHead(status, res.headers);
720 if (status === 204 || chunks.length === 0) input.res.end();
721 else input.res.end(Buffer.concat(chunks));
722 });
723 });
724 },
725 );
726 req.on("error", (err) => {
727 input.res.writeHead(502, { "content-type": "text/plain" });
728 input.res.end(`Proxy error: ${(err && err.message) || err}`);
729 });
730 return req;
731};
732
733const attachSidecarLoopback = async (
734 socketPath: string,
735 containerId: string,
736 ports: readonly number[],
737) => {
738 if (!usesUnixSocketLoopback() || ports.length === 0) return;
739 let inspect: {
740 Id?: string;
741 Name?: string;
742 State?: { Pid?: number };
743 };
744 try {
745 inspect = await dockerApiJson(
746 socketPath,
747 "GET",
748 `/containers/${containerId}/json`,
749 );
750 } catch (error) {
751 console.warn(
752 `alchemy: could not inspect ${containerId} for loopback forwards (${String(error)})`,
753 );
754 return;
755 }
756 const name = inspect.Name?.replace(/^\//, "") ?? "";
757 if (!name.endsWith("-proxy")) return;
758 const result = attachLoopbackNetnsForwarder({
759 keys: [containerId, inspect.Id ?? "", name],
760 pid: inspect.State?.Pid ?? 0,
761 ports,
762 });
763 if (!result.ok) {
764 console.warn(
765 `alchemy: could not attach loopback unix-socket forwards in container netns (${result.error}). ` +
766 `A SYN to host-gateway is host INPUT and UFW may drop it. Do not set DEFAULT_INPUT_POLICY=ACCEPT. ` +
767 `If you must punch a hole: ${ufwAllowHint(ports)}`,
768 );
769 }
770};
771
772const dockerApiJson = <T>(
773 socketPath: string,
774 method: string,
775 path: string,
776): Promise<T> =>
777 new Promise((resolve, reject) => {
778 const req = NodeHttp.request(
779 {
780 socketPath: socketPath.replace(/^unix:/, ""),
781 path,
782 method,
783 },
784 (res) => {
785 const chunks: Array<Buffer> = [];
786 res.on("data", (chunk) => chunks.push(chunk));
787 res.on("end", () => {
788 const body = Buffer.concat(chunks).toString();
789 if ((res.statusCode ?? 500) >= 300) {
790 reject(new Error(`docker ${method} ${path}: ${res.statusCode}`));
791 return;
792 }
793 try {
794 resolve(JSON.parse(body) as T);
795 } catch (error) {
796 reject(error);
797 }
798 });
799 },
800 );
801 req.on("error", reject);
802 req.end();
803 });
804
805const extractJsonBody = <T>(req: NodeHttp.IncomingMessage) => {
806 const promise = Promise.withResolvers<T>();
807 const chunks: Array<Buffer> = [];
808 req.pipe(
809 new NodeStream.Writable({
810 write(chunk, _encoding, callback) {
811 chunks.push(chunk);
812 callback();
813 },
814 final(callback) {
815 promise.resolve(JSON.parse(Buffer.concat(chunks).toString()));
816 callback();
817 },
818 }),
819 { end: true },
820 );
821 return promise.promise;
822};