1/**
2 * `@alchemy.run/floci` — Effect-native manager for the
3 * [floci](https://floci.io) local cloud emulator.
4 *
5 * alchemy's AWS local dev points its providers at a floci endpoint. This
6 * package owns the emulator lifecycle with a strict resolution order that
7 * keeps every local loop registry-free:
8 *
9 * 1. **Already serving** — if anything answers on the endpoint (e.g.
10 * `bun floci:dev` running `mvn quarkus:dev` from a floci checkout, or a
11 * hand-run container), it wins. Nothing is started, nothing owned.
12 * 2. **`ALCHEMY_FLOCI_IMAGE`** — run a specific image (e.g. a locally
13 * built `floci:dev` from a patched fork).
14 * 3. **The pinned release** — {@link DEFAULT_FLOCI_IMAGE}, alchemy's
15 * distribution of floci (upstream + alchemy patch set, published from
16 * github.com/alchemy-run/floci).
17 *
18 * The container is started detached with a stable name — the DOCKER DAEMON
19 * is the supervisor, not alchemy: it outlives `alchemy dev` sessions, so
20 * queues/tables/functions stay warm between runs and the next session
21 * reattaches via the health probe in milliseconds.
22 */
23import * as Data from "effect/Data";
24import * as Duration from "effect/Duration";
25import * as Effect from "effect/Effect";
26import * as Schedule from "effect/Schedule";
27import * as Semaphore from "effect/Semaphore";
28import { execFile } from "node:child_process";
29import * as fs from "node:fs/promises";
30import * as net from "node:net";
31import * as os from "node:os";
32import * as path from "node:path";
33
34/**
35 * The pinned floci release alchemy ships: upstream + the alchemy patch
36 * set, built and published by the alchemy-run/floci fork's release
37 * workflow. Bumping this pin (with a new `x.y.z-alchemy.N` tag) is how an
38 * emulator change reaches users.
39 */
40export const DEFAULT_FLOCI_IMAGE = "ghcr.io/alchemy-run/floci:1.6.0-alchemy.11";
41
42/** Default gateway port (LocalStack-compatible). */
43export const DEFAULT_FLOCI_PORT = 4566;
44
45/**
46 * ELB listener ports published on the managed container. Floci's ELBv2
47 * data plane serves each listener on the listener's OWN port inside the
48 * container (see floci's ports doc) — its `*.elb.localhost.floci.io` DNS
49 * resolves to 127.0.0.1, so the port must be published for the emulated
50 * load balancer to be reachable from the host. Ports already taken on the
51 * host are skipped (the gateway still works without them). A warning is
52 * emitted only when the caller asked for those ports via
53 * {@link FlociConfig.elbListenerPorts} — the default 80/443 publish is
54 * opportunistic and a taken privileged port is the common case on a
55 * developer machine, not a problem.
56 */
57export const DEFAULT_ELB_LISTENER_PORTS = [80, 443];
58
59/**
60 * Ports published for floci's emulated CloudFront edge. Each emulated
61 * distribution is served on one of these as plain HTTP, so `alchemy dev`
62 * hands out a `router.url` a browser can open — a distribution's
63 * `*.cloudfront.net` domain resolves to nothing on a developer's machine.
64 * The emulator assigns a port per distribution and reports it back; it can
65 * only use ports the container actually published, so the resolved list is
66 * passed in as `FLOCI_SERVICES_CLOUDFRONT_EDGE_PORTS`. Taken host ports are
67 * skipped, exactly like the ELB listener ports above.
68 */
69export const DEFAULT_CLOUDFRONT_EDGE_PORTS = Array.from(
70 { length: 20 },
71 (_, index) => 9500 + index,
72);
73
74/** Default name of the managed container. */
75export const DEFAULT_CONTAINER_NAME = "alchemy-floci";
76
77/**
78 * Stable host path of the emulator's self-signed CA bundle, refreshed by
79 * {@link ensureFloci} on every successful health check (the CA is minted
80 * inside the container, so it changes whenever the container is recreated).
81 * Home-anchored (not cwd-relative) so every process in a dev session — test
82 * runner, RPC sidecars, workerd children — resolves the same file; consumers
83 * point TLS trust at it (e.g. `NODE_EXTRA_CA_CERTS`, which the local workerd
84 * runtime folds into its outbound `trustedCertificates`).
85 */
86export const FLOCI_CA_PATH = path.join(os.homedir(), ".floci", "ca.pem");
87
88/**
89 * Host directory mounted at the container's TLS dir (`/app/data/tls`) so
90 * the self-signed CA (which floci reuses when the files already exist)
91 * SURVIVES container recreations. Without it, every recreate — an image
92 * bump, a port-config change, a Docker daemon restart — mints a fresh CA,
93 * and every process that loaded the previous bundle (workerd trust stores
94 * read once at spawn) fails TLS against the emulator until restarted.
95 */
96export const FLOCI_TLS_DIR = path.join(os.homedir(), ".floci", "tls");
97
98/**
99 * Best-effort download of the emulator's CA bundle to {@link FLOCI_CA_PATH}.
100 * Never fails the ensure: an emulator predating `/_floci/tls/ca` (or a
101 * filesystem error) just leaves the previous bundle in place.
102 */
103const syncCaBundle = (endpoint: string): Effect.Effect<void> =>
104 Effect.tryPromise({
105 try: async (signal) => {
106 const res = await fetch(`${endpoint}/_floci/tls/ca`, { signal });
107 if (!res.ok) return;
108 const pem = await res.text();
109 if (!pem.includes("BEGIN CERTIFICATE")) return;
110 await fs.mkdir(path.dirname(FLOCI_CA_PATH), { recursive: true });
111 await fs.writeFile(FLOCI_CA_PATH, pem);
112 // Point local workerd (and any other child process spawned after this)
113 // at the emulator CA so cross-cloud MicroVM data-plane TLS is trusted
114 // under `alchemy dev` — Node reads NODE_EXTRA_CA_CERTS once at startup,
115 // so setting it here (before local workers spawn during the apply) only
116 // affects children, which is exactly the target. Never clobber a value
117 // the caller set deliberately.
118 if (process.env.NODE_EXTRA_CA_CERTS === undefined) {
119 process.env.NODE_EXTRA_CA_CERTS = FLOCI_CA_PATH;
120 }
121 },
122 catch: (cause) => new FlociError({ message: "ca sync failed", cause }),
123 }).pipe(Effect.ignore);
124
125export class FlociError extends Data.TaggedError("FlociError")<{
126 readonly message: string;
127 readonly cause?: unknown;
128}> {}
129
130export interface FlociConfig {
131 /**
132 * Image to run when the emulator is not already serving.
133 * Resolution order: `ALCHEMY_FLOCI_IMAGE` env var, this field,
134 * {@link DEFAULT_FLOCI_IMAGE}.
135 */
136 readonly image?: string | undefined;
137 /**
138 * Gateway port on the host.
139 * @default 4566
140 */
141 readonly port?: number | undefined;
142 /**
143 * Name of the managed container (stable so `docker start` can revive a
144 * stopped one).
145 * @default "alchemy-floci"
146 */
147 readonly containerName?: string | undefined;
148 /**
149 * Host directory persisted into the container's data dir. Omit for
150 * floci's default in-memory storage (state lives as long as the
151 * container).
152 */
153 readonly storageDir?: string | undefined;
154 /**
155 * Mount the host Docker socket so floci's container-backed engines
156 * (Lambda, ECS, EC2, RDS, ElastiCache) work.
157 * @default true
158 */
159 readonly dockerSocket?: boolean | undefined;
160 /**
161 * Extra environment variables for the emulator (e.g.
162 * `FLOCI_SERVICES_LAMBDA_HOT_RELOAD_ENABLED`).
163 */
164 readonly env?: Record<string, string> | undefined;
165 /**
166 * ELB listener ports to publish on the managed container (floci's ALB
167 * data plane serves each listener on the listener's own port). Ports
168 * already taken on the host are skipped (with a warning only when this
169 * field is set — the default 80/443 publish is opportunistic).
170 * @default [80, 443]
171 */
172 readonly elbListenerPorts?: readonly number[] | undefined;
173 /**
174 * Ports to publish for the emulated CloudFront edge (floci serves each
175 * distribution on its own port). Ports already taken on the host are
176 * skipped; an empty result turns the feature off in the emulator so it
177 * never binds a port nothing can reach.
178 * @default 9500-9519
179 */
180 readonly cloudfrontEdgePorts?: readonly number[] | undefined;
181 /**
182 * How long to wait for the emulator to become healthy after starting it
183 * (first run includes the image pull).
184 * @default 180 seconds
185 */
186 readonly readinessTimeout?: Duration.Input | undefined;
187}
188
189/** A running (or reused) emulator. */
190export interface FlociInstance {
191 /** Gateway endpoint, e.g. `http://localhost:4566`. */
192 readonly endpoint: string;
193 /**
194 * Whether this manager started the container (`false` when something was
195 * already serving on the endpoint — a dev-mode JVM, a hand-run
196 * container, or a previous session's container).
197 */
198 readonly managed: boolean;
199 /** Container name when {@link managed} is true. */
200 readonly containerName?: string | undefined;
201}
202
203const docker = (args: ReadonlyArray<string>) =>
204 Effect.callback<{ stdout: string; stderr: string }, FlociError>((resume) => {
205 const child = execFile(
206 "docker",
207 args as Array<string>,
208 { maxBuffer: 16 * 1024 * 1024 },
209 (error, stdout, stderr) => {
210 if (error) {
211 resume(
212 Effect.fail(
213 new FlociError({
214 message: `docker ${args[0]} failed: ${stderr.trim() || error.message}`,
215 cause: error,
216 }),
217 ),
218 );
219 } else {
220 resume(Effect.succeed({ stdout, stderr }));
221 }
222 },
223 );
224 return Effect.sync(() => child.kill());
225 });
226
227/**
228 * Whether anything answers HTTP on the endpoint. Any response — even an
229 * error status — counts as serving; only a transport failure means down.
230 */
231export const isServing = (endpoint: string): Effect.Effect<boolean> =>
232 Effect.tryPromise({
233 try: async (signal) => {
234 await fetch(`${endpoint}/_floci/health`, { signal });
235 return true;
236 },
237 catch: () => new FlociError({ message: "unreachable" }),
238 }).pipe(Effect.orElseSucceed(() => false));
239
240/** Fail unless `GET /_floci/health` answers 200. */
241export const checkHealth = (
242 endpoint: string,
243): Effect.Effect<void, FlociError> =>
244 Effect.tryPromise({
245 try: async (signal) => {
246 const res = await fetch(`${endpoint}/_floci/health`, { signal });
247 if (!res.ok) {
248 throw new Error(`health returned ${res.status}`);
249 }
250 },
251 catch: (cause) =>
252 new FlociError({
253 message: `floci health check failed: ${String(cause)}`,
254 cause,
255 }),
256 });
257
258/** Resolve the image to run: env override, config, pinned default. */
259export const resolveImage = (config?: FlociConfig): Effect.Effect<string> =>
260 Effect.sync(
261 () =>
262 // `|| undefined`: an empty env var (`ALCHEMY_FLOCI_IMAGE= cmd`) means
263 // "no override", not "run the image named ''" (an unrunnable docker ref).
264 (process.env.ALCHEMY_FLOCI_IMAGE || undefined) ??
265 config?.image ??
266 DEFAULT_FLOCI_IMAGE,
267 );
268
269/** Whether a host TCP port is free (nothing is listening on it). */
270const isHostPortFree = (port: number): Effect.Effect<boolean> =>
271 Effect.callback<boolean>((resume) => {
272 const server = net.createServer();
273 server.once("error", (error: NodeJS.ErrnoException) => {
274 if (error.code === "EACCES") {
275 // Binding low ports (<1024) is privileged for THIS process, but
276 // Docker's helper can still publish them — fall back to a
277 // connect probe: a refused connection means nothing listens.
278 const socket = net.connect({ port, host: "127.0.0.1" });
279 socket.once("connect", () => {
280 socket.destroy();
281 resume(Effect.succeed(false));
282 });
283 socket.once("error", () => resume(Effect.succeed(true)));
284 return;
285 }
286 resume(Effect.succeed(false));
287 });
288 server.once("listening", () =>
289 server.close(() => resume(Effect.succeed(true))),
290 );
291 server.listen(port, "127.0.0.1");
292 return Effect.sync(() => server.close());
293 });
294
295/**
296 * The host ports the named container publishes, or `undefined` when the
297 * container does not exist.
298 */
299const publishedPorts = (
300 containerName: string,
301): Effect.Effect<Set<number> | undefined> =>
302 docker([
303 "inspect",
304 "--format",
305 "{{json .HostConfig.PortBindings}}",
306 containerName,
307 ]).pipe(
308 Effect.map(({ stdout }) => {
309 let bindings: Record<string, Array<{ HostPort?: string }> | null> | null;
310 try {
311 bindings = JSON.parse(stdout.trim());
312 } catch {
313 // Racing a concurrent create/remove of the container can produce
314 // partial output — treat it as "unknown", not a crash.
315 return undefined;
316 }
317 const ports = new Set<number>();
318 for (const spec of Object.values(bindings ?? {})) {
319 for (const bind of spec ?? []) {
320 const hostPort = Number(bind.HostPort);
321 if (Number.isFinite(hostPort)) ports.add(hostPort);
322 }
323 }
324 return ports;
325 }),
326 Effect.orElseSucceed(() => undefined),
327 );
328
329/**
330 * The requested ports the managed container should publish: every port that
331 * is either already published by the container or still free on the host.
332 * Taken ports are skipped (with a warning only when the caller asked for
333 * them explicitly) so a busy host port never blocks the emulator from
334 * starting.
335 */
336const resolvePublishablePorts = Effect.fn(function* (
337 requested: readonly number[],
338 published: ReadonlySet<number>,
339 onTaken?: (port: number) => Effect.Effect<void>,
340) {
341 const desired: number[] = [];
342 for (const port of requested) {
343 if (published.has(port) || (yield* isHostPortFree(port))) {
344 desired.push(port);
345 } else if (onTaken !== undefined) {
346 yield* onTaken(port);
347 }
348 }
349 return desired;
350});
351
352/**
353 * Ensure a floci emulator is serving and healthy, starting the managed
354 * container if needed. Idempotent and cheap when already up (one HTTP
355 * probe); see the module doc for the resolution order.
356 */
357/**
358 * One `ensureFloci` at a time per process. Callers memoize per stack
359 * build, but separate builds (concurrent tests, parallel dev commands) can
360 * ensure at the same moment: when the managed container needs recreating,
361 * two callers would both `docker rm -f` it ("removal of container … is
362 * already in progress") and then race `docker run` on the name. Under the
363 * mutex the second caller observes the container the first one started.
364 */
365const ensureMutex = Semaphore.makeUnsafe(1);
366
367export const ensureFloci = (
368 config?: FlociConfig,
369): Effect.Effect<FlociInstance, FlociError> =>
370 ensureMutex.withPermits(1)(
371 // The mutex serializes ensures within THIS process; across processes
372 // (a test runner and a dev CLI booting together) the recreate path can
373 // still race — one process's `rm -f` can delete the container the
374 // other just created, and the loser's `docker run` fails (sometimes
375 // with an empty stderr). Re-running the whole ensure converges: the
376 // retry re-observes whatever the winner left serving and reuses it.
377 ensureFlociUnsynchronized(config).pipe(
378 Effect.retry({
379 schedule: Schedule.spaced("2 seconds"),
380 times: 3,
381 }),
382 ),
383 );
384
385const ensureFlociUnsynchronized = (
386 config?: FlociConfig,
387): Effect.Effect<FlociInstance, FlociError> =>
388 Effect.gen(function* () {
389 const port = config?.port ?? DEFAULT_FLOCI_PORT;
390 const endpoint = `http://localhost:${port}`;
391 const containerName = config?.containerName ?? DEFAULT_CONTAINER_NAME;
392 const published = yield* publishedPorts(containerName);
393 const elbPorts = yield* resolvePublishablePorts(
394 config?.elbListenerPorts ?? DEFAULT_ELB_LISTENER_PORTS,
395 published ?? new Set(),
396 // Default 80/443 are opportunistic (most stacks have no ELB). A taken
397 // privileged port is the common case on a developer machine, not a
398 // problem — warn only when the caller asked for those ports.
399 config?.elbListenerPorts === undefined
400 ? undefined
401 : (port) =>
402 Effect.logWarning(
403 `floci: host port ${port} is taken — emulated load balancer listeners on ${port} will not be reachable from the host`,
404 ),
405 );
406 const edgePorts = yield* resolvePublishablePorts(
407 config?.cloudfrontEdgePorts ?? DEFAULT_CLOUDFRONT_EDGE_PORTS,
408 published ?? new Set(),
409 );
410
411 // Converge the managed container's EXPLICITLY-requested ports and its
412 // image: a container missing ports the caller configured can't serve
413 // them, and one running a superseded emulator image (a floci release
414 // bump, or a switch of `ALCHEMY_FLOCI_IMAGE`) would silently miss
415 // emulator features the providers now rely on — so either is recreated
416 // (emulated state is ephemeral by design; the next apply reconciles
417 // it). The OPPORTUNISTIC defaults (80/443, the CloudFront edge range)
418 // deliberately do NOT trigger recreation: which of them resolve as
419 // publishable depends on what happens to be free in the observing
420 // process, so keying recreation on them makes concurrent consumers
421 // (a test run and a dev CLI) recreate each other's container forever.
422 // An UNMANAGED server (a dev-mode JVM, a hand-run container) is never
423 // touched. To keep running a hand-built image under the managed name,
424 // keep `ALCHEMY_FLOCI_IMAGE` set — the resolution order is the
425 // contract.
426 const publishPorts = [...elbPorts, ...edgePorts];
427 const requiredPorts = [
428 ...(config?.elbListenerPorts ?? []).filter((p) => elbPorts.includes(p)),
429 ...(config?.cloudfrontEdgePorts ?? []).filter((p) =>
430 edgePorts.includes(p),
431 ),
432 ];
433 const image = yield* resolveImage(config);
434 const runningImage =
435 published !== undefined
436 ? yield* docker([
437 "inspect",
438 "--format",
439 "{{.Config.Image}}",
440 containerName,
441 ]).pipe(
442 Effect.map(({ stdout }) => stdout.trim() || undefined),
443 Effect.orElseSucceed(() => undefined),
444 )
445 : undefined;
446 const needsRecreate =
447 published !== undefined &&
448 (requiredPorts.some((p) => !published.has(p)) ||
449 (runningImage !== undefined && runningImage !== image));
450
451 if (yield* isServing(endpoint)) {
452 if (!needsRecreate) {
453 // Something may be serving but still starting — the readiness
454 // poll tolerates transient unhealthiness.
455 yield* checkHealth(endpoint).pipe(
456 Effect.catch(() => waitForHealth(endpoint, config)),
457 );
458 yield* syncCaBundle(endpoint);
459 return { endpoint, managed: false };
460 }
461 // Only recreate when the server IS our managed container (otherwise
462 // removing the container would not free the endpoint anyway).
463 yield* Effect.logInfo(
464 `floci: recreating ${containerName} (image ${runningImage} -> ${image}, ports [${publishPorts.join(", ")}])`,
465 );
466 yield* docker(["rm", "-f", containerName]);
467 } else if (needsRecreate) {
468 yield* Effect.logInfo(
469 `floci: recreating ${containerName} (image ${runningImage} -> ${image}, ports [${publishPorts.join(", ")}])`,
470 );
471 yield* docker(["rm", "-f", containerName]);
472 } else {
473 // Revive a stopped container from a previous session, else run fresh.
474 const started = yield* docker(["start", containerName]).pipe(
475 Effect.map(() => true),
476 Effect.orElseSucceed(() => false),
477 );
478 if (started) {
479 yield* waitForHealth(endpoint, config);
480 yield* syncCaBundle(endpoint);
481 return { endpoint, managed: true, containerName };
482 }
483 }
484
485 yield* Effect.tryPromise({
486 try: () => fs.mkdir(FLOCI_TLS_DIR, { recursive: true }),
487 catch: (cause) => new FlociError({ message: "tls dir", cause }),
488 }).pipe(Effect.ignore);
489 const args = [
490 "run",
491 "-d",
492 "--name",
493 containerName,
494 "-p",
495 `${port}:4566`,
496 // Docker Desktop injects `host.docker.internal` automatically; native
497 // Linux Docker does not. The emulator needs it to reach a dev server
498 // running on the developer's machine (a CloudFront origin pointed at
499 // `localhost:<port>`, for one).
500 "--add-host",
501 "host.docker.internal:host-gateway",
502 ...publishPorts.flatMap((p) => ["-p", `${p}:${p}`]),
503 // The emulator can only hand a distribution a port the container
504 // published, so it gets the resolved list rather than its own default
505 // range — and the feature is switched off outright when nothing could
506 // be published, so it never binds a port nothing can reach.
507 "-e",
508 edgePorts.length > 0
509 ? `FLOCI_SERVICES_CLOUDFRONT_EDGE_PORTS=${edgePorts.join(",")}`
510 : "FLOCI_SERVICES_CLOUDFRONT_EDGE_PORTS_ENABLED=false",
511 ...(config?.dockerSocket === false
512 ? []
513 : ["-v", "/var/run/docker.sock:/var/run/docker.sock", "-u", "root"]),
514 ...(config?.storageDir !== undefined
515 ? [
516 "-v",
517 `${config.storageDir}:/app/data`,
518 "-e",
519 "FLOCI_STORAGE_MODE=hybrid",
520 ]
521 : // No full storage mount: still persist the TLS dir so the
522 // self-signed CA is stable across container recreations (floci
523 // reuses existing cert files).
524 ["-v", `${FLOCI_TLS_DIR}:/app/data/tls`]),
525 ...Object.entries(config?.env ?? {}).flatMap(([key, value]) => [
526 "-e",
527 `${key}=${value}`,
528 ]),
529 image,
530 ];
531 yield* docker(args).pipe(
532 // A concurrent ensureFloci (e.g. the dev sidecar racing the CLI's
533 // exec child on a machine where floci is not yet running) may have
534 // created the container between our checks and this run. Docker
535 // reports that as a name conflict — but not reliably: the losing
536 // `docker run` has been observed exiting non-zero with an EMPTY
537 // stderr right after a Docker Desktop restart, so the error text
538 // cannot be trusted. Ask Docker whether the container exists now and
539 // reuse it if so; only a run that left nothing behind is a failure.
540 Effect.catch((error) =>
541 docker(["inspect", "--format", "{{.Id}}", containerName]).pipe(
542 Effect.flatMap(({ stdout }) =>
543 stdout.trim() ? Effect.void : Effect.fail(error),
544 ),
545 Effect.catch(() => Effect.fail(error)),
546 ),
547 ),
548 );
549
550 yield* waitForHealth(endpoint, config);
551 yield* syncCaBundle(endpoint);
552 return { endpoint, managed: true, containerName };
553 });
554
555const waitForHealth = (endpoint: string, config?: FlociConfig) =>
556 checkHealth(endpoint).pipe(
557 Effect.retry({
558 schedule: Schedule.spaced("1 second"),
559 times: Math.max(
560 1,
561 Math.ceil(
562 Duration.toSeconds(
563 // 300s: a freshly (re)started Docker daemon can take minutes to
564 // create the first container from the 1.3GB image; giving up
565 // early parks `alchemy dev` on a FlociError until the next file
566 // edit, which reads as a wedge.
567 Duration.fromInputUnsafe(config?.readinessTimeout ?? "300 seconds"),
568 ),
569 ),
570 ),
571 }),
572 Effect.mapError(
573 (error) =>
574 new FlociError({
575 message:
576 `floci did not become healthy at ${endpoint}: ${error.message}. ` +
577 "Check `docker logs " +
578 (config?.containerName ?? DEFAULT_CONTAINER_NAME) +
579 "`.",
580 cause: error,
581 }),
582 ),
583 );
584
585/** Stop (not remove) the managed container. */
586export const stopFloci = (
587 containerName: string = DEFAULT_CONTAINER_NAME,
588): Effect.Effect<void, FlociError> =>
589 Effect.asVoid(docker(["stop", containerName]));
590
591/** Remove the managed container (and its in-memory state). */
592export const destroyFloci = (
593 containerName: string = DEFAULT_CONTAINER_NAME,
594): Effect.Effect<void, FlociError> =>
595 Effect.asVoid(docker(["rm", "-f", containerName]));
596
597/** Tail of the managed container's logs. */
598export const flociLogs = (
599 containerName: string = DEFAULT_CONTAINER_NAME,
600 lines = 200,
601): Effect.Effect<string, FlociError> =>
602 Effect.map(
603 docker(["logs", "--tail", String(lines), containerName]),
604 ({ stdout, stderr }) => stdout + stderr,
605 );